feat: overhaul with modular API, agent endpoints, migrations, backup and tests
- Split monolithic app.py into server/api/ routers - Add Pydantic schemas for input validation - Add /agent/* API with separate AGENT_TOKEN auth - Replace static schema.sql with versioned migrations (datenerhaltend) - Add tools/backup_db.py for SQLite backups - Add Dockerfile HEALTHCHECK and compose.yml AGENT_TOKEN env - Add pytest tests for user and agent API - Update README with API docs and deployment safety notes - Bump version to 1.2.0
This commit is contained in:
@@ -5,6 +5,10 @@ APP_PIN=1234
|
|||||||
JWT_SECRET=change-me-to-a-long-random-string
|
JWT_SECRET=change-me-to-a-long-random-string
|
||||||
JWT_EXPIRE_DAYS=90
|
JWT_EXPIRE_DAYS=90
|
||||||
|
|
||||||
|
# Agent API — zufälliger langer String für externe Agents (openssl rand -hex 32)
|
||||||
|
# Leer lassen deaktiviert /agent/* Endpunkte.
|
||||||
|
AGENT_TOKEN=
|
||||||
|
|
||||||
# VAPID keys für Web Push — generieren mit: python tools/gen_vapid.py
|
# VAPID keys für Web Push — generieren mit: python tools/gen_vapid.py
|
||||||
VAPID_PRIVATE_KEY=
|
VAPID_PRIVATE_KEY=
|
||||||
VAPID_PUBLIC_KEY=
|
VAPID_PUBLIC_KEY=
|
||||||
|
|||||||
@@ -11,3 +11,5 @@ venv/
|
|||||||
*.egg-info/
|
*.egg-info/
|
||||||
public/assets/*
|
public/assets/*
|
||||||
!public/assets/.gitkeep
|
!public/assets/.gitkeep
|
||||||
|
tests/test.sqlite
|
||||||
|
tests/fixtures/
|
||||||
|
|||||||
@@ -17,4 +17,7 @@ COPY meds.yaml messages.yaml /app/
|
|||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health/')" || exit 1
|
||||||
|
|
||||||
CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ Persönliche Medikamenten-PWA mit dezenten Push-Erinnerungen, Einnahme-Logging u
|
|||||||
- **Push-Erinnerungen** um 8:00 und 12:00 (`Europe/Berlin`) — dezent (`silent`), kein Alarm
|
- **Push-Erinnerungen** um 8:00 und 12:00 (`Europe/Berlin`) — dezent (`silent`), kein Alarm
|
||||||
- **Einnahme loggen:** genommen / verpasst / snooze
|
- **Einnahme loggen:** genommen / verpasst / snooze
|
||||||
- **PIN-Login** mit langer JWT-Session (90 Tage)
|
- **PIN-Login** mit langer JWT-Session (90 Tage)
|
||||||
|
- **Agent-API** für externe Automatisierung (`/agent/*`) mit separatem Token
|
||||||
- **PWA:** installierbar, offline-fähige App-Shell, Service Worker
|
- **PWA:** installierbar, offline-fähige App-Shell, Service Worker
|
||||||
|
- **DB-Migrationen** — versionierte SQL-Skripte für deploymentsichere Schema-Updates
|
||||||
|
|
||||||
### Spaß
|
### Spaß
|
||||||
- **Dopamin-Drop** — Animation + Erfolgsspruch beim Loggen
|
- **Dopamin-Drop** — Animation + Erfolgsspruch beim Loggen
|
||||||
@@ -78,7 +80,7 @@ App: http://localhost:8000
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# .env ausfüllen (PIN, JWT_SECRET, VAPID keys, optional OPENROUTER_API_KEY)
|
# .env ausfüllen (PIN, JWT_SECRET, AGENT_TOKEN, VAPID keys, optional OPENROUTER_API_KEY)
|
||||||
|
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
@@ -86,9 +88,22 @@ docker compose up -d --build
|
|||||||
Traefik routet `medis.schwenk.online` → Container Port 8000 (TLS via `myresolver`).
|
Traefik routet `medis.schwenk.online` → Container Port 8000 (TLS via `myresolver`).
|
||||||
|
|
||||||
Volumes:
|
Volumes:
|
||||||
- `./data` — SQLite-Datenbank
|
- `./data` — SQLite-Datenbank + automatische Backups (`data/backups/`)
|
||||||
- `./meds.yaml` — Medikamenten-Zeitplan (read-only)
|
- `./meds.yaml` — Medikamenten-Zeitplan (read-only)
|
||||||
- `./messages.yaml` — Humor-Texte (read-only)
|
- `./messages.yaml` — Humor-Texte (read-only)
|
||||||
|
- `./tools/migrations` — Schema-Migrationen (read-only)
|
||||||
|
|
||||||
|
### Keine Daten verlieren beim Deploy
|
||||||
|
|
||||||
|
1. **DB-Migrationen:** `tools/migrations/*.sql` werden beim Container-Start automatisch angewendet (`schema_version`-Tabelle). Neue Tabellen/Spalten kommen ohne Datenverlust dazu.
|
||||||
|
2. **Backup vor Updates:**
|
||||||
|
```bash
|
||||||
|
python tools/backup_db.py
|
||||||
|
# oder im Container:
|
||||||
|
docker compose exec app python tools/backup_db.py
|
||||||
|
```
|
||||||
|
3. **Volumes nicht löschen:** `docker compose down` ohne `-v` lässt `./data` bestehen. Nie `data/` manuell löschen.
|
||||||
|
4. **Healthcheck:** Der Container meldet sich über `/api/health/`; Docker wartet mit dem "healthy"-Status, bis die App antwortet.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -101,6 +116,7 @@ Volumes:
|
|||||||
| `APP_PIN` | Login-PIN (4–6 Ziffern) |
|
| `APP_PIN` | Login-PIN (4–6 Ziffern) |
|
||||||
| `JWT_SECRET` | Geheimer Key für JWT (min. 32 Zeichen empfohlen: `openssl rand -hex 32`) |
|
| `JWT_SECRET` | Geheimer Key für JWT (min. 32 Zeichen empfohlen: `openssl rand -hex 32`) |
|
||||||
| `JWT_EXPIRE_DAYS` | Session-Laufzeit (Default: 90) |
|
| `JWT_EXPIRE_DAYS` | Session-Laufzeit (Default: 90) |
|
||||||
|
| `AGENT_TOKEN` | Langer zufälliger Token für die `/agent/*` API (leer = deaktiviert) |
|
||||||
| `VAPID_PRIVATE_KEY` | Web-Push Private Key (PEM) |
|
| `VAPID_PRIVATE_KEY` | Web-Push Private Key (PEM) |
|
||||||
| `VAPID_PUBLIC_KEY` | Web-Push Public Key |
|
| `VAPID_PUBLIC_KEY` | Web-Push Public Key |
|
||||||
| `VAPID_CLAIMS_EMAIL` | mailto:-Adresse für VAPID |
|
| `VAPID_CLAIMS_EMAIL` | mailto:-Adresse für VAPID |
|
||||||
@@ -133,12 +149,13 @@ Texte frei editierbar — kein Neustart nötig (wird beim ersten Request geladen
|
|||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
Alle Endpunkte unter `/api/*`. Auth via `Authorization: Bearer <token>` (außer `/api/auth/pin` und `/api/vapid-public-key`).
|
Alle Endpunkte unter `/api/*`. Auth via `Authorization: Bearer <token>` (außer `/api/auth/pin`, `/api/health/` und `/api/vapid-public-key`).
|
||||||
|
|
||||||
| Methode | Pfad | Beschreibung |
|
| Methode | Pfad | Beschreibung |
|
||||||
|---------|------|--------------|
|
|---------|------|--------------|
|
||||||
|
| GET | `/api/health/` | Liveness + App-Version |
|
||||||
| POST | `/api/auth/pin` | `{ "pin": "1234" }` → `{ "token": "..." }` |
|
| POST | `/api/auth/pin` | `{ "pin": "1234" }` → `{ "token": "..." }` |
|
||||||
| GET | `/api/vapid-public-key` | Public Key für Push-Subscription |
|
| GET | `/api/vapid-public-key` | Public Key für Push-Subscription (Legacy-Pfad) |
|
||||||
| GET | `/api/config` | Meds-Config |
|
| GET | `/api/config` | Meds-Config |
|
||||||
| GET | `/api/today` | Heutige Slots + Status |
|
| GET | `/api/today` | Heutige Slots + Status |
|
||||||
| POST | `/api/log` | `{ "slot_id", "status", "day?", "source?" }` |
|
| POST | `/api/log` | `{ "slot_id", "status", "day?", "source?" }` |
|
||||||
@@ -146,8 +163,24 @@ Alle Endpunkte unter `/api/*`. Auth via `Authorization: Bearer <token>` (außer
|
|||||||
| GET | `/api/history?days=90` | Heatmap-Daten + Stats |
|
| GET | `/api/history?days=90` | Heatmap-Daten + Stats |
|
||||||
| GET | `/api/stats` | Streak, Compliance, Meilensteine |
|
| GET | `/api/stats` | Streak, Compliance, Meilensteine |
|
||||||
| GET | `/api/roast` | Roast-of-the-Day |
|
| GET | `/api/roast` | Roast-of-the-Day |
|
||||||
|
| GET | `/api/motivation` | Tägliche Motivation |
|
||||||
| POST | `/api/push/subscribe` | Web-Push Subscription speichern |
|
| POST | `/api/push/subscribe` | Web-Push Subscription speichern |
|
||||||
|
|
||||||
|
### Agent-API
|
||||||
|
|
||||||
|
Separate API für externe Agents / Automatisierung. Aktivieren mit `AGENT_TOKEN` in `.env`. Auth ebenfalls via `Authorization: Bearer <agent_token>`.
|
||||||
|
|
||||||
|
| Methode | Pfad | Beschreibung |
|
||||||
|
|---------|------|--------------|
|
||||||
|
| GET | `/agent/health` | Status, App-Version, DB-Schema-Version |
|
||||||
|
| GET | `/agent/status` | Heutiger Stand + Stats |
|
||||||
|
| POST | `/agent/log` | `{ "slot_id", "status", "day?", "source?" }` |
|
||||||
|
| GET | `/agent/history?days=90` | Heatmap-Daten + Stats |
|
||||||
|
| GET | `/agent/config` | Meds-Config |
|
||||||
|
| POST | `/agent/notify` | `{ "title", "body", "slot_id?", "tag?" }` — Push an alle Subscriptions |
|
||||||
|
|
||||||
|
Die Agent-API läuft auf denselben Tabellen wie die App; `source` ist frei wählbar, z. B. `"voice"`, `"watch"`, `"cron"`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## PWA / Push einrichten
|
## PWA / Push einrichten
|
||||||
@@ -186,6 +219,8 @@ Notification-Actions: **Genommen ✓**, **+15 Min**, **+30 Min**
|
|||||||
| **Icons: PNG für alle Größen** | 556×556 Quelle — Browser skaliert; kein ImageMagick nötig |
|
| **Icons: PNG für alle Größen** | 556×556 Quelle — Browser skaliert; kein ImageMagick nötig |
|
||||||
| **Abend-Check 21:00** | Markiert überfällige Slots als `missed`, optional Push |
|
| **Abend-Check 21:00** | Markiert überfällige Slots als `missed`, optional Push |
|
||||||
| **Offline-Queue via IndexedDB** | Einfacher als SW-only; Flush bei App-Start + Background-Sync |
|
| **Offline-Queue via IndexedDB** | Einfacher als SW-only; Flush bei App-Start + Background-Sync |
|
||||||
|
| **Agent-API mit separatem Token** | Externe Automatisierung ohne User-PIN; lässt sich gezielt abschalten |
|
||||||
|
| **Versionierte SQL-Migrationen** | Kein Alembic-Overhead; deploymentsicher, datenerhaltend |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -193,16 +228,20 @@ Notification-Actions: **Genommen ✓**, **+15 Min**, **+30 Min**
|
|||||||
|
|
||||||
```
|
```
|
||||||
takeyourmeds/
|
takeyourmeds/
|
||||||
|
├── server/ # FastAPI Backend
|
||||||
|
│ ├── api/ # API-Router (auth, intake, push, stats, content, agent, health)
|
||||||
|
│ ├── schemas.py # Pydantic-Input-Modelle
|
||||||
|
│ ├── migrations.py # DB-Migrationssystem
|
||||||
|
│ └── ...
|
||||||
├── frontend/ # PWA-Quellen (JS, CSS, Templates)
|
├── frontend/ # PWA-Quellen (JS, CSS, Templates)
|
||||||
|
├── public/ # Build-Output (index.html, sw.js, assets/)
|
||||||
|
├── tools/ # migrations/, gen_vapid.py, build_frontend.py, backup_db.py
|
||||||
├── compose.yml # Docker + Traefik
|
├── compose.yml # Docker + Traefik
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
├── meds.yaml # Medikamenten-Zeitplan
|
├── meds.yaml # Medikamenten-Zeitplan
|
||||||
├── messages.yaml # Humor-Pool
|
├── messages.yaml # Humor-Pool
|
||||||
├── icon.png / icon.svg # App-Icons (Quelle)
|
├── icon.png / icon.svg # App-Icons (Quelle)
|
||||||
├── server/ # FastAPI Backend
|
└── data/ # SQLite + Backups (gitignored)
|
||||||
├── public/ # Build-Output (index.html, sw.js, assets/)
|
|
||||||
├── tools/ # schema.sql, gen_vapid.py, build_frontend.py
|
|
||||||
└── data/ # SQLite (gitignored)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -219,6 +258,12 @@ Quellen liegen in `frontend/`; Build-Output in `public/` (gehashte JS/CSS unter
|
|||||||
|
|
||||||
Service Worker: bei Deploy neuer `CACHE`-Name automatisch via Build. Update-Banner erscheint bei veralteter Client-Version.
|
Service Worker: bei Deploy neuer `CACHE`-Name automatisch via Build. Update-Banner erscheint bei veralteter Client-Version.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ services:
|
|||||||
- APP_PIN=${APP_PIN}
|
- APP_PIN=${APP_PIN}
|
||||||
- JWT_SECRET=${JWT_SECRET}
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
- JWT_EXPIRE_DAYS=${JWT_EXPIRE_DAYS:-90}
|
- JWT_EXPIRE_DAYS=${JWT_EXPIRE_DAYS:-90}
|
||||||
|
- AGENT_TOKEN=${AGENT_TOKEN:-}
|
||||||
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
|
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
|
||||||
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
|
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
|
||||||
- VAPID_CLAIMS_EMAIL=${VAPID_CLAIMS_EMAIL:-mailto:admin@schwenk.online}
|
- VAPID_CLAIMS_EMAIL=${VAPID_CLAIMS_EMAIL:-mailto:admin@schwenk.online}
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = "tym-v1.1.0-914619f3";
|
const CACHE = "tym-v1.2.0-914619f3";
|
||||||
const ASSETS = [
|
const ASSETS = [
|
||||||
"/",
|
"/",
|
||||||
"/index.html",
|
"/index.html",
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"buildTime": "2026-07-08T06:50:31.880953Z",
|
"buildTime": "2026-09-21T10:33:11.129661Z",
|
||||||
"buildHash": "914619f3"
|
"buildHash": "914619f3"
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-2
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "takeyourmeds"
|
name = "takeyourmeds"
|
||||||
version = "1.1.0"
|
version = "1.2.0"
|
||||||
description = "Medikamenten-PWA mit Push-Erinnerungen und sarkastischem Humor"
|
description = "Medikamenten-PWA mit Push-Erinnerungen, Agent-API und sarkastischem Humor"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.115",
|
"fastapi>=0.115",
|
||||||
@@ -16,6 +16,13 @@ dependencies = [
|
|||||||
"apscheduler>=3.10",
|
"apscheduler>=3.10",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
test = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-asyncio>=0.23",
|
||||||
|
"httpx>=0.27",
|
||||||
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=70", "wheel"]
|
requires = ["setuptools>=70", "wheel"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ async def _recent_roasts(
|
|||||||
before_day: str,
|
before_day: str,
|
||||||
days: int = ROAST_HISTORY_DAYS,
|
days: int = ROAST_HISTORY_DAYS,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
end = datetime.strptime(before_day, "%Y-%m-%d").date()
|
end = datetime.strptime(before_day, "%Y-%m-%d").replace(tzinfo=UTC).date()
|
||||||
roasts: list[str] = []
|
roasts: list[str] = []
|
||||||
for offset in range(1, days + 1):
|
for offset in range(1, days + 1):
|
||||||
day = (end - timedelta(days=offset)).isoformat()
|
day = (end - timedelta(days=offset)).isoformat()
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from server.auth import require_agent
|
||||||
|
from server.config_loader import load_meds_config, slot_to_dict, today_str
|
||||||
|
from server.db import get_db, new_id, utc_now_iso
|
||||||
|
from server.migrations import current_version
|
||||||
|
from server.push import send_push
|
||||||
|
from server.schemas import AgentLogEntry, AgentNotifyRequest
|
||||||
|
from server.settings import settings
|
||||||
|
from server.slots import build_today
|
||||||
|
from server.stats import build_history, compute_stats
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/agent", tags=["agent"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
NO_STORE = {"Cache-Control": "no-store"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def agent_health(_: dict = Depends(require_agent)) -> JSONResponse:
|
||||||
|
db = await get_db()
|
||||||
|
try:
|
||||||
|
db_version = await current_version(db)
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
return JSONResponse(
|
||||||
|
{"status": "ok", "version": settings.app_version, "db_version": db_version},
|
||||||
|
headers=NO_STORE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def agent_status(_: dict = Depends(require_agent)) -> JSONResponse:
|
||||||
|
config = load_meds_config()
|
||||||
|
db = await get_db()
|
||||||
|
try:
|
||||||
|
today_data = await build_today(db, config)
|
||||||
|
stats = await compute_stats(db)
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
return JSONResponse({"today": today_data, "stats": stats}, headers=NO_STORE)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/log")
|
||||||
|
async def agent_log(payload: AgentLogEntry, _: dict = Depends(require_agent)) -> JSONResponse:
|
||||||
|
config = load_meds_config()
|
||||||
|
if payload.slot_id not in {s.id for s in config.slots}:
|
||||||
|
raise HTTPException(status_code=400, detail="Unknown slot")
|
||||||
|
|
||||||
|
day = payload.day.isoformat() if payload.day else today_str(config.timezone)
|
||||||
|
logged_at = payload.logged_at or utc_now_iso()
|
||||||
|
status = payload.status.value
|
||||||
|
|
||||||
|
db = await get_db()
|
||||||
|
try:
|
||||||
|
await db.execute("DELETE FROM intake_log WHERE slot_id = ? AND day = ?", (payload.slot_id, day))
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO intake_log (id, slot_id, day, status, logged_at, source) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(new_id(), payload.slot_id, day, status, logged_at, payload.source),
|
||||||
|
)
|
||||||
|
if status == "taken":
|
||||||
|
await db.execute("DELETE FROM snooze WHERE slot_id = ? AND day = ?", (payload.slot_id, day))
|
||||||
|
await db.commit()
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
logger.info("Agent logged slot=%s status=%s day=%s", payload.slot_id, status, day)
|
||||||
|
return JSONResponse({"ok": True, "slot_id": payload.slot_id, "day": day, "status": status}, headers=NO_STORE)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
async def agent_history(days: int = 90, _: dict = Depends(require_agent)) -> JSONResponse:
|
||||||
|
db = await get_db()
|
||||||
|
try:
|
||||||
|
data = await build_history(db, days=min(days, 365))
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
return JSONResponse(data, headers=NO_STORE)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config")
|
||||||
|
async def agent_config(_: dict = Depends(require_agent)) -> JSONResponse:
|
||||||
|
config = load_meds_config()
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"timezone": str(config.timezone),
|
||||||
|
"slots": [slot_to_dict(s) for s in config.slots],
|
||||||
|
"version": settings.app_version,
|
||||||
|
},
|
||||||
|
headers=NO_STORE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/notify")
|
||||||
|
async def agent_notify(payload: AgentNotifyRequest, _: dict = Depends(require_agent)) -> JSONResponse:
|
||||||
|
db = await get_db()
|
||||||
|
try:
|
||||||
|
sent = await send_push(
|
||||||
|
db,
|
||||||
|
{
|
||||||
|
"title": payload.title,
|
||||||
|
"body": payload.body,
|
||||||
|
"slot_id": payload.slot_id or "agent",
|
||||||
|
"tag": payload.tag or "agent",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
return JSONResponse({"ok": True, "sent": sent}, headers=NO_STORE)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from server.auth import create_token, verify_pin
|
||||||
|
from server.schemas import PinLogin
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
NO_STORE = {"Cache-Control": "no-store"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pin")
|
||||||
|
async def auth_pin(payload: PinLogin) -> JSONResponse:
|
||||||
|
if not verify_pin(payload.pin):
|
||||||
|
logger.warning("Failed PIN login attempt")
|
||||||
|
raise HTTPException(status_code=401, detail="Falscher PIN. Dein Gehirn auch.")
|
||||||
|
return JSONResponse({"token": create_token()}, headers=NO_STORE)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from server.auth import require_auth
|
||||||
|
from server.config_loader import load_meds_config, slot_to_dict
|
||||||
|
from server.settings import settings
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/config", tags=["config"])
|
||||||
|
|
||||||
|
NO_STORE = {"Cache-Control": "no-store"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def get_config(_: dict = Depends(require_auth)) -> JSONResponse:
|
||||||
|
config = load_meds_config()
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"timezone": str(config.timezone),
|
||||||
|
"slots": [slot_to_dict(s) for s in config.slots],
|
||||||
|
"version": settings.app_version,
|
||||||
|
},
|
||||||
|
headers=NO_STORE,
|
||||||
|
)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from server.ai import get_daily_motivation, get_roast_of_the_day
|
||||||
|
from server.auth import require_auth
|
||||||
|
from server.config_loader import load_meds_config
|
||||||
|
from server.db import get_db
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["content"])
|
||||||
|
|
||||||
|
NO_STORE = {"Cache-Control": "no-store"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/roast")
|
||||||
|
async def get_roast(_: dict = Depends(require_auth)) -> JSONResponse:
|
||||||
|
config = load_meds_config()
|
||||||
|
db = await get_db()
|
||||||
|
try:
|
||||||
|
data = await get_roast_of_the_day(db, config.timezone)
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
return JSONResponse(data, headers=NO_STORE)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/motivation")
|
||||||
|
async def get_motivation(_: dict = Depends(require_auth)) -> JSONResponse:
|
||||||
|
config = load_meds_config()
|
||||||
|
db = await get_db()
|
||||||
|
try:
|
||||||
|
data = await get_daily_motivation(db, config.timezone)
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
return JSONResponse(data, headers=NO_STORE)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from server.settings import settings
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/health", tags=["health"])
|
||||||
|
|
||||||
|
NO_STORE = {"Cache-Control": "no-store"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def health_check() -> JSONResponse:
|
||||||
|
return JSONResponse(
|
||||||
|
{"status": "ok", "version": settings.app_version},
|
||||||
|
headers=NO_STORE,
|
||||||
|
)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from server.auth import require_auth
|
||||||
|
from server.config_loader import load_meds_config, today_str
|
||||||
|
from server.db import get_db, new_id, utc_now_iso
|
||||||
|
from server.messages import pick
|
||||||
|
from server.scheduler import schedule_snooze
|
||||||
|
from server.schemas import LogEntry, SnoozeRequest
|
||||||
|
from server.slots import build_today
|
||||||
|
from server.stats import MILESTONE_DEFS, build_history, check_milestones, compute_stats
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["intake"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
NO_STORE = {"Cache-Control": "no-store"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/today")
|
||||||
|
async def get_today(_: dict = Depends(require_auth)) -> JSONResponse:
|
||||||
|
db = await get_db()
|
||||||
|
try:
|
||||||
|
data = await build_today(db)
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
return JSONResponse(data, headers=NO_STORE)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/log")
|
||||||
|
async def post_log(payload: LogEntry, _: dict = Depends(require_auth)) -> JSONResponse:
|
||||||
|
config = load_meds_config()
|
||||||
|
if payload.slot_id not in {s.id for s in config.slots}:
|
||||||
|
raise HTTPException(status_code=400, detail="Unknown slot")
|
||||||
|
|
||||||
|
day = payload.day.isoformat() if payload.day else today_str(config.timezone)
|
||||||
|
logged_at = payload.logged_at or utc_now_iso()
|
||||||
|
status = payload.status.value
|
||||||
|
|
||||||
|
db = await get_db()
|
||||||
|
try:
|
||||||
|
await db.execute("DELETE FROM intake_log WHERE slot_id = ? AND day = ?", (payload.slot_id, day))
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO intake_log (id, slot_id, day, status, logged_at, source) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(new_id(), payload.slot_id, day, status, logged_at, payload.source),
|
||||||
|
)
|
||||||
|
if status == "taken":
|
||||||
|
await db.execute("DELETE FROM snooze WHERE slot_id = ? AND day = ?", (payload.slot_id, day))
|
||||||
|
await db.commit()
|
||||||
|
stats = await compute_stats(db)
|
||||||
|
new_milestones = await check_milestones(db, stats["streak"])
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
message = pick("success") if status == "taken" else pick("missed")
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"message": message,
|
||||||
|
"stats": stats,
|
||||||
|
"new_milestones": [
|
||||||
|
{"id": m, "title": MILESTONE_DEFS.get(m, m)} for m in new_milestones
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers=NO_STORE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
async def get_history(days: int = 90, _: dict = Depends(require_auth)) -> JSONResponse:
|
||||||
|
db = await get_db()
|
||||||
|
try:
|
||||||
|
data = await build_history(db, days=min(days, 365))
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
return JSONResponse(data, headers=NO_STORE)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/snooze")
|
||||||
|
async def post_snooze(payload: SnoozeRequest, _: dict = Depends(require_auth)) -> JSONResponse:
|
||||||
|
config = load_meds_config()
|
||||||
|
if payload.slot_id not in {s.id for s in config.slots}:
|
||||||
|
raise HTTPException(status_code=400, detail="Unknown slot")
|
||||||
|
|
||||||
|
until = await schedule_snooze(payload.slot_id, payload.minutes)
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": True, "snooze_until": until, "message": pick("snooze")},
|
||||||
|
headers=NO_STORE,
|
||||||
|
)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from server.auth import require_auth
|
||||||
|
from server.db import get_db
|
||||||
|
from server.push import save_subscription
|
||||||
|
from server.schemas import PushSubscribe
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/push", tags=["push"])
|
||||||
|
|
||||||
|
NO_STORE = {"Cache-Control": "no-store"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vapid-public-key")
|
||||||
|
async def vapid_public_key() -> JSONResponse:
|
||||||
|
from server.settings import settings
|
||||||
|
|
||||||
|
return JSONResponse({"key": settings.vapid_public_key}, headers=NO_STORE)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/subscribe")
|
||||||
|
async def push_subscribe(payload: PushSubscribe, _: dict = Depends(require_auth)) -> JSONResponse:
|
||||||
|
if not payload.subscription or not payload.subscription.get("endpoint"):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid subscription")
|
||||||
|
db = await get_db()
|
||||||
|
try:
|
||||||
|
await save_subscription(db, payload.subscription)
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
return JSONResponse({"ok": True}, headers=NO_STORE)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from server.auth import require_auth
|
||||||
|
from server.db import get_db
|
||||||
|
from server.stats import compute_stats
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/stats", tags=["stats"])
|
||||||
|
|
||||||
|
NO_STORE = {"Cache-Control": "no-store"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def get_stats(_: dict = Depends(require_auth)) -> JSONResponse:
|
||||||
|
db = await get_db()
|
||||||
|
try:
|
||||||
|
stats = await compute_stats(db)
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
return JSONResponse(stats, headers=NO_STORE)
|
||||||
+18
-152
@@ -4,24 +4,18 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import pathlib
|
import pathlib
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
from fastapi import FastAPI
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from server.ai import generate_daily_motivation, generate_roast_of_the_day, get_roast_of_the_day
|
from server.ai import generate_daily_motivation, generate_roast_of_the_day
|
||||||
from server.auth import create_token, require_auth, verify_pin
|
from server.api import agent, auth, config, content, health, intake, push, stats
|
||||||
from server.config_loader import load_meds_config, slot_to_dict, today_str
|
from server.config_loader import load_meds_config
|
||||||
from server.db import ensure_schema, get_db, new_id, utc_now_iso
|
from server.db import ensure_schema, get_db
|
||||||
from server.messages import pick
|
from server.scheduler import start_scheduler
|
||||||
from server.push import save_subscription, send_slot_reminder
|
|
||||||
from server.scheduler import schedule_snooze, start_scheduler
|
|
||||||
from server.settings import settings
|
from server.settings import settings
|
||||||
from server.slots import build_today
|
|
||||||
from server.static_cache import CacheControlMiddleware
|
from server.static_cache import CacheControlMiddleware
|
||||||
from server.stats import build_history, check_milestones, compute_stats, MILESTONE_DEFS
|
|
||||||
|
|
||||||
NO_STORE = {"Cache-Control": "no-store"}
|
NO_STORE = {"Cache-Control": "no-store"}
|
||||||
|
|
||||||
@@ -66,150 +60,22 @@ async def lifespan(app: FastAPI):
|
|||||||
app = FastAPI(title="TakeYourMeds", version=settings.app_version, lifespan=lifespan)
|
app = FastAPI(title="TakeYourMeds", version=settings.app_version, lifespan=lifespan)
|
||||||
app.add_middleware(CacheControlMiddleware)
|
app.add_middleware(CacheControlMiddleware)
|
||||||
|
|
||||||
|
# API routers
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(config.router)
|
||||||
|
app.include_router(intake.router)
|
||||||
|
app.include_router(push.router)
|
||||||
|
app.include_router(content.router)
|
||||||
|
app.include_router(stats.router)
|
||||||
|
app.include_router(health.router)
|
||||||
|
app.include_router(agent.router)
|
||||||
|
|
||||||
@app.post("/api/auth/pin")
|
# Backwards-compatible VAPID endpoint used by older clients
|
||||||
async def auth_pin(payload: dict[str, Any]) -> JSONResponse:
|
|
||||||
pin = str(payload.get("pin", ""))
|
|
||||||
if not verify_pin(pin):
|
|
||||||
raise HTTPException(status_code=401, detail="Falscher PIN")
|
|
||||||
return JSONResponse({"token": create_token()}, headers=NO_STORE)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/vapid-public-key")
|
@app.get("/api/vapid-public-key")
|
||||||
async def vapid_public_key() -> JSONResponse:
|
async def vapid_public_key_legacy() -> JSONResponse:
|
||||||
return JSONResponse({"key": settings.vapid_public_key}, headers=NO_STORE)
|
return JSONResponse({"key": settings.vapid_public_key}, headers=NO_STORE)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/config")
|
|
||||||
async def get_config(_: dict = Depends(require_auth)) -> JSONResponse:
|
|
||||||
config = load_meds_config()
|
|
||||||
return JSONResponse(
|
|
||||||
{
|
|
||||||
"timezone": str(config.timezone),
|
|
||||||
"slots": [slot_to_dict(s) for s in config.slots],
|
|
||||||
"version": settings.app_version,
|
|
||||||
},
|
|
||||||
headers=NO_STORE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/today")
|
|
||||||
async def get_today(_: dict = Depends(require_auth)) -> JSONResponse:
|
|
||||||
db = await get_db()
|
|
||||||
try:
|
|
||||||
data = await build_today(db)
|
|
||||||
finally:
|
|
||||||
await db.close()
|
|
||||||
return JSONResponse(data, headers=NO_STORE)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/log")
|
|
||||||
async def post_log(payload: dict[str, Any], _: dict = Depends(require_auth)) -> JSONResponse:
|
|
||||||
slot_id = str(payload.get("slot_id", ""))
|
|
||||||
status = str(payload.get("status", "taken"))
|
|
||||||
source = str(payload.get("source", "app"))
|
|
||||||
|
|
||||||
if status not in ("taken", "missed", "snoozed"):
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid status")
|
|
||||||
|
|
||||||
config = load_meds_config()
|
|
||||||
if slot_id not in {s.id for s in config.slots}:
|
|
||||||
raise HTTPException(status_code=400, detail="Unknown slot")
|
|
||||||
|
|
||||||
day = payload.get("day") or today_str(config.timezone)
|
|
||||||
logged_at = payload.get("logged_at") or utc_now_iso()
|
|
||||||
|
|
||||||
db = await get_db()
|
|
||||||
try:
|
|
||||||
await db.execute("DELETE FROM intake_log WHERE slot_id = ? AND day = ?", (slot_id, day))
|
|
||||||
await db.execute(
|
|
||||||
"INSERT INTO intake_log (id, slot_id, day, status, logged_at, source) VALUES (?, ?, ?, ?, ?, ?)",
|
|
||||||
(new_id(), slot_id, day, status, logged_at, source),
|
|
||||||
)
|
|
||||||
if status == "taken":
|
|
||||||
await db.execute("DELETE FROM snooze WHERE slot_id = ? AND day = ?", (slot_id, day))
|
|
||||||
await db.commit()
|
|
||||||
stats = await compute_stats(db)
|
|
||||||
new_milestones = await check_milestones(db, stats["streak"])
|
|
||||||
finally:
|
|
||||||
await db.close()
|
|
||||||
|
|
||||||
message = pick("success") if status == "taken" else pick("missed")
|
|
||||||
return JSONResponse(
|
|
||||||
{
|
|
||||||
"ok": True,
|
|
||||||
"message": message,
|
|
||||||
"stats": stats,
|
|
||||||
"new_milestones": [
|
|
||||||
{"id": m, "title": MILESTONE_DEFS.get(m, m)} for m in new_milestones
|
|
||||||
],
|
|
||||||
},
|
|
||||||
headers=NO_STORE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/history")
|
|
||||||
async def get_history(days: int = 90, _: dict = Depends(require_auth)) -> JSONResponse:
|
|
||||||
db = await get_db()
|
|
||||||
try:
|
|
||||||
data = await build_history(db, days=min(days, 365))
|
|
||||||
finally:
|
|
||||||
await db.close()
|
|
||||||
return JSONResponse(data, headers=NO_STORE)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/stats")
|
|
||||||
async def get_stats(_: dict = Depends(require_auth)) -> JSONResponse:
|
|
||||||
db = await get_db()
|
|
||||||
try:
|
|
||||||
stats = await compute_stats(db)
|
|
||||||
finally:
|
|
||||||
await db.close()
|
|
||||||
return JSONResponse(stats, headers=NO_STORE)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/push/subscribe")
|
|
||||||
async def push_subscribe(payload: dict[str, Any], _: dict = Depends(require_auth)) -> JSONResponse:
|
|
||||||
sub = payload.get("subscription")
|
|
||||||
if not sub or not sub.get("endpoint"):
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid subscription")
|
|
||||||
db = await get_db()
|
|
||||||
try:
|
|
||||||
await save_subscription(db, sub)
|
|
||||||
finally:
|
|
||||||
await db.close()
|
|
||||||
return JSONResponse({"ok": True}, headers=NO_STORE)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/snooze")
|
|
||||||
async def post_snooze(payload: dict[str, Any], _: dict = Depends(require_auth)) -> JSONResponse:
|
|
||||||
slot_id = str(payload.get("slot_id", ""))
|
|
||||||
minutes = int(payload.get("minutes", 15))
|
|
||||||
if minutes not in (15, 30):
|
|
||||||
raise HTTPException(status_code=400, detail="minutes must be 15 or 30")
|
|
||||||
|
|
||||||
config = load_meds_config()
|
|
||||||
if slot_id not in {s.id for s in config.slots}:
|
|
||||||
raise HTTPException(status_code=400, detail="Unknown slot")
|
|
||||||
|
|
||||||
until = await schedule_snooze(slot_id, minutes)
|
|
||||||
return JSONResponse(
|
|
||||||
{"ok": True, "snooze_until": until, "message": pick("snooze")},
|
|
||||||
headers=NO_STORE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/roast")
|
|
||||||
async def get_roast(_: dict = Depends(require_auth)) -> JSONResponse:
|
|
||||||
config = load_meds_config()
|
|
||||||
db = await get_db()
|
|
||||||
try:
|
|
||||||
data = await get_roast_of_the_day(db, config.timezone)
|
|
||||||
finally:
|
|
||||||
await db.close()
|
|
||||||
return JSONResponse(data, headers=NO_STORE)
|
|
||||||
|
|
||||||
|
|
||||||
# Static files — must be after API routes
|
# Static files — must be after API routes
|
||||||
PUBLIC = pathlib.Path(settings.public_dir)
|
PUBLIC = pathlib.Path(settings.public_dir)
|
||||||
if PUBLIC.exists():
|
if PUBLIC.exists():
|
||||||
|
|||||||
+21
-3
@@ -1,11 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hmac
|
import hmac
|
||||||
from datetime import datetime, timedelta, timezone
|
import secrets
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from fastapi import Depends, HTTPException, Request
|
from fastapi import Depends, HTTPException, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
from server.settings import settings
|
from server.settings import settings
|
||||||
@@ -19,7 +20,7 @@ def verify_pin(pin: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def create_token() -> str:
|
def create_token() -> str:
|
||||||
expire = datetime.now(timezone.utc) + timedelta(days=settings.jwt_expire_days)
|
expire = datetime.now(UTC) + timedelta(days=settings.jwt_expire_days)
|
||||||
payload = {"sub": "user", "exp": expire}
|
payload = {"sub": "user", "exp": expire}
|
||||||
return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM)
|
return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM)
|
||||||
|
|
||||||
@@ -37,3 +38,20 @@ async def require_auth(
|
|||||||
if creds is None or creds.scheme.lower() != "bearer":
|
if creds is None or creds.scheme.lower() != "bearer":
|
||||||
raise HTTPException(status_code=401, detail="Missing token")
|
raise HTTPException(status_code=401, detail="Missing token")
|
||||||
return decode_token(creds.credentials)
|
return decode_token(creds.credentials)
|
||||||
|
|
||||||
|
|
||||||
|
async def require_agent(
|
||||||
|
creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Authenticate external agents via AGENT_TOKEN (long-lived, not JWT)."""
|
||||||
|
if creds is None or creds.scheme.lower() != "bearer":
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing agent token")
|
||||||
|
token = creds.credentials
|
||||||
|
if not settings.agent_token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Agent API not configured",
|
||||||
|
)
|
||||||
|
if not secrets.compare_digest(token.strip(), settings.agent_token.strip()):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid agent token")
|
||||||
|
return {"sub": "agent"}
|
||||||
|
|||||||
+5
-7
@@ -2,21 +2,19 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import pathlib
|
import pathlib
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
||||||
from server.settings import settings
|
from server.settings import settings
|
||||||
|
|
||||||
SCHEMA_PATH = str(pathlib.Path(__file__).resolve().parents[1] / "tools" / "schema.sql")
|
|
||||||
|
|
||||||
|
|
||||||
def new_id() -> str:
|
def new_id() -> str:
|
||||||
return str(uuid.uuid4())
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
def utc_now_iso() -> str:
|
def utc_now_iso() -> str:
|
||||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
return datetime.now(UTC).replace(microsecond=0).isoformat()
|
||||||
|
|
||||||
|
|
||||||
async def get_db() -> aiosqlite.Connection:
|
async def get_db() -> aiosqlite.Connection:
|
||||||
@@ -27,6 +25,6 @@ async def get_db() -> aiosqlite.Connection:
|
|||||||
|
|
||||||
|
|
||||||
async def ensure_schema(db: aiosqlite.Connection) -> None:
|
async def ensure_schema(db: aiosqlite.Connection) -> None:
|
||||||
schema = pathlib.Path(SCHEMA_PATH).read_text(encoding="utf-8")
|
from server.migrations import run_migrations
|
||||||
await db.executescript(schema)
|
|
||||||
await db.commit()
|
await run_migrations(db)
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
from server.settings import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_MIGRATION_RE = re.compile(r"^(\d+)_.*\.sql$")
|
||||||
|
|
||||||
|
|
||||||
|
def _migrations_dir() -> pathlib.Path:
|
||||||
|
path = pathlib.Path(settings.migrations_dir)
|
||||||
|
if path.is_absolute():
|
||||||
|
return path
|
||||||
|
return pathlib.Path(__file__).resolve().parents[1] / path
|
||||||
|
|
||||||
|
|
||||||
|
def _available_migrations() -> list[tuple[int, pathlib.Path]]:
|
||||||
|
directory = _migrations_dir()
|
||||||
|
if not directory.exists():
|
||||||
|
return []
|
||||||
|
migrations: list[tuple[int, pathlib.Path]] = []
|
||||||
|
for item in directory.iterdir():
|
||||||
|
match = _MIGRATION_RE.match(item.name)
|
||||||
|
if match and item.is_file():
|
||||||
|
migrations.append((int(match.group(1)), item))
|
||||||
|
migrations.sort(key=lambda x: x[0])
|
||||||
|
return migrations
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_meta_table(db: aiosqlite.Connection) -> None:
|
||||||
|
await db.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def current_version(db: aiosqlite.Connection) -> int:
|
||||||
|
await _ensure_meta_table(db)
|
||||||
|
cur = await db.execute("SELECT MAX(version) FROM schema_version")
|
||||||
|
row = await cur.fetchone()
|
||||||
|
await cur.close()
|
||||||
|
return row[0] or 0 if row else 0
|
||||||
|
|
||||||
|
|
||||||
|
async def _record_version(db: aiosqlite.Connection, version: int) -> None:
|
||||||
|
from server.db import utc_now_iso
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO schema_version (version, applied_at) VALUES (?, ?)",
|
||||||
|
(version, utc_now_iso()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_migrations(db: aiosqlite.Connection) -> None:
|
||||||
|
"""Apply pending SQL migrations safely. Idempotent, data-preserving."""
|
||||||
|
await _ensure_meta_table(db)
|
||||||
|
current = await current_version(db)
|
||||||
|
available = _available_migrations()
|
||||||
|
|
||||||
|
pending = [(v, p) for v, p in available if v > current]
|
||||||
|
if not pending:
|
||||||
|
logger.info("Database schema up to date (version %d)", current)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Applying %d pending migration(s) (current version %d)", len(pending), current)
|
||||||
|
for version, path in pending:
|
||||||
|
sql = path.read_text(encoding="utf-8")
|
||||||
|
logger.info("Applying migration %d: %s", version, path.name)
|
||||||
|
await db.executescript(sql)
|
||||||
|
await _record_version(db, version)
|
||||||
|
await db.commit()
|
||||||
|
logger.info("Migration %d applied", version)
|
||||||
|
|
||||||
|
logger.info("Database migrations complete (version %d)", await current_version(db))
|
||||||
+3
-4
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
@@ -10,11 +9,11 @@ from apscheduler.triggers.cron import CronTrigger
|
|||||||
from apscheduler.triggers.date import DateTrigger
|
from apscheduler.triggers.date import DateTrigger
|
||||||
|
|
||||||
from server.ai import generate_daily_motivation, generate_roast_of_the_day
|
from server.ai import generate_daily_motivation, generate_roast_of_the_day
|
||||||
from server.config_loader import load_meds_config, slot_to_dict, today_str
|
from server.config_loader import load_meds_config, today_str
|
||||||
from server.db import get_db, utc_now_iso
|
from server.db import get_db
|
||||||
from server.messages import pick
|
from server.messages import pick
|
||||||
from server.push import send_slot_reminder
|
from server.push import send_slot_reminder
|
||||||
from server.slots import build_today, get_log_for_day, mark_missed_for_overdue
|
from server.slots import get_log_for_day, mark_missed_for_overdue
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
scheduler = AsyncIOScheduler()
|
scheduler = AsyncIOScheduler()
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class LogStatus(str, Enum):
|
||||||
|
taken = "taken"
|
||||||
|
missed = "missed"
|
||||||
|
snoozed = "snoozed"
|
||||||
|
|
||||||
|
|
||||||
|
class PinLogin(BaseModel):
|
||||||
|
pin: str = Field(..., min_length=3, max_length=12)
|
||||||
|
|
||||||
|
|
||||||
|
class LogEntry(BaseModel):
|
||||||
|
slot_id: str = Field(..., min_length=1, max_length=64)
|
||||||
|
status: LogStatus = LogStatus.taken
|
||||||
|
day: date | None = None
|
||||||
|
logged_at: str | None = None
|
||||||
|
source: str = "app"
|
||||||
|
|
||||||
|
|
||||||
|
class SnoozeRequest(BaseModel):
|
||||||
|
slot_id: str = Field(..., min_length=1, max_length=64)
|
||||||
|
minutes: int = Field(...)
|
||||||
|
|
||||||
|
@field_validator("minutes")
|
||||||
|
@classmethod
|
||||||
|
def allowed_snooze(cls, v: int) -> int:
|
||||||
|
if v not in (15, 30):
|
||||||
|
raise ValueError("minutes must be 15 or 30")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class PushSubscribe(BaseModel):
|
||||||
|
subscription: dict
|
||||||
|
|
||||||
|
|
||||||
|
class AgentLogEntry(BaseModel):
|
||||||
|
slot_id: str = Field(..., min_length=1, max_length=64)
|
||||||
|
status: LogStatus = LogStatus.taken
|
||||||
|
day: date | None = None
|
||||||
|
logged_at: str | None = None
|
||||||
|
source: str = "agent"
|
||||||
|
|
||||||
|
|
||||||
|
class AgentNotifyRequest(BaseModel):
|
||||||
|
title: str = Field(..., min_length=1, max_length=120)
|
||||||
|
body: str = Field(..., min_length=1, max_length=512)
|
||||||
|
slot_id: str | None = None
|
||||||
|
tag: str | None = None
|
||||||
+4
-1
@@ -10,6 +10,8 @@ class Settings(BaseSettings):
|
|||||||
jwt_secret: str = "dev-secret-change-me"
|
jwt_secret: str = "dev-secret-change-me"
|
||||||
jwt_expire_days: int = 90
|
jwt_expire_days: int = 90
|
||||||
|
|
||||||
|
agent_token: str = "" # long-lived token for /agent/* endpoints
|
||||||
|
|
||||||
vapid_private_key: str = ""
|
vapid_private_key: str = ""
|
||||||
vapid_public_key: str = ""
|
vapid_public_key: str = ""
|
||||||
vapid_claims_email: str = "mailto:admin@schwenk.online"
|
vapid_claims_email: str = "mailto:admin@schwenk.online"
|
||||||
@@ -22,7 +24,8 @@ class Settings(BaseSettings):
|
|||||||
messages_path: str = "messages.yaml"
|
messages_path: str = "messages.yaml"
|
||||||
|
|
||||||
public_dir: str = "public"
|
public_dir: str = "public"
|
||||||
app_version: str = "1.1.0"
|
app_version: str = "1.2.0"
|
||||||
|
migrations_dir: str = "tools/migrations"
|
||||||
|
|
||||||
|
|
||||||
settings = Settings() # type: ignore[call-arg]
|
settings = Settings() # type: ignore[call-arg]
|
||||||
|
|||||||
+7
-1
@@ -6,7 +6,13 @@ from zoneinfo import ZoneInfo
|
|||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
||||||
from server.config_loader import MedsConfig, Slot, load_meds_config, slot_datetime, today_str
|
from server.config_loader import (
|
||||||
|
MedsConfig,
|
||||||
|
Slot,
|
||||||
|
load_meds_config,
|
||||||
|
slot_datetime,
|
||||||
|
today_str,
|
||||||
|
)
|
||||||
from server.db import utc_now_iso
|
from server.db import utc_now_iso
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ from zoneinfo import ZoneInfo
|
|||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
||||||
from server.config_loader import load_meds_config, today_str
|
from server.config_loader import load_meds_config, today_str
|
||||||
from server.db import new_id, utc_now_iso
|
from server.db import utc_now_iso
|
||||||
from server.slots import compute_status, get_log_for_day, get_snooze_for_day
|
from server.slots import compute_status, get_log_for_day, get_snooze_for_day
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Set environment variables BEFORE importing the app or settings module.
|
||||||
|
# Use a fixed test workspace so every module that imports settings sees the
|
||||||
|
# same paths. The client fixture wipes the DB before each test.
|
||||||
|
TEST_DIR = Path(__file__).resolve().parent
|
||||||
|
TEST_DB = TEST_DIR / "test.sqlite"
|
||||||
|
TEST_MEDS = TEST_DIR / "fixtures" / "meds.yaml"
|
||||||
|
TEST_MESSAGES = TEST_DIR / "fixtures" / "messages.yaml"
|
||||||
|
|
||||||
|
os.environ["APP_PIN"] = "1234"
|
||||||
|
os.environ["JWT_SECRET"] = "test-secret-must-be-at-least-32-bytes-long-ok"
|
||||||
|
os.environ["AGENT_TOKEN"] = "agent-test-token-must-be-long-enough-for-secrets"
|
||||||
|
os.environ["VAPID_PRIVATE_KEY"] = ""
|
||||||
|
os.environ["VAPID_PUBLIC_KEY"] = ""
|
||||||
|
os.environ["OPENROUTER_API_KEY"] = ""
|
||||||
|
os.environ["DB_PATH"] = str(TEST_DB)
|
||||||
|
os.environ["MEDS_PATH"] = str(TEST_MEDS)
|
||||||
|
os.environ["MESSAGES_PATH"] = str(TEST_MESSAGES)
|
||||||
|
|
||||||
|
# Neutralize the background scheduler for tests — it binds to the asyncio loop
|
||||||
|
# and survives poorly across multiple TestClient lifecycles.
|
||||||
|
import server.scheduler
|
||||||
|
|
||||||
|
server.scheduler.start_scheduler = lambda: None # type: ignore[assignment]
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from server.app import app
|
||||||
|
from server.auth import create_token
|
||||||
|
from server.config_loader import reload_meds_config
|
||||||
|
|
||||||
|
|
||||||
|
def _write_fixtures() -> None:
|
||||||
|
TEST_MEDS.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
TEST_MEDS.write_text(
|
||||||
|
"timezone: Europe/Berlin\n"
|
||||||
|
"slots:\n"
|
||||||
|
" - id: morning\n"
|
||||||
|
" time: \"08:00\"\n"
|
||||||
|
" label: Morgens\n"
|
||||||
|
" meds:\n"
|
||||||
|
" - name: Elvanse\n"
|
||||||
|
" dose: 30mg\n"
|
||||||
|
" reminder_window_minutes: 90\n"
|
||||||
|
)
|
||||||
|
TEST_MESSAGES.write_text(
|
||||||
|
"reminder: [\"Zeit für Medis.\"]\n"
|
||||||
|
"success: [\"Genommen.\"]\n"
|
||||||
|
"missed: [\"Verpasst.\"]\n"
|
||||||
|
"snooze: [\"Snooze.\"]\n"
|
||||||
|
"motivation_fallback: [\"Motivation.\"]\n"
|
||||||
|
"roast_fallback: [\"Roast.\"]\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_write_fixtures()
|
||||||
|
reload_meds_config()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def client():
|
||||||
|
if TEST_DB.exists():
|
||||||
|
TEST_DB.unlink()
|
||||||
|
with TestClient(app) as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user_token() -> str:
|
||||||
|
return create_token()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_headers(user_token: str):
|
||||||
|
return {"Authorization": f"Bearer {user_token}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def agent_headers():
|
||||||
|
return {"Authorization": "Bearer agent-test-token-must-be-long-enough-for-secrets"}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
def test_health(client: TestClient) -> None:
|
||||||
|
resp = client.get("/api/health/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_ok(client: TestClient) -> None:
|
||||||
|
resp = client.post("/api/auth/pin", json={"pin": "1234"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "token" in resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_wrong(client: TestClient) -> None:
|
||||||
|
resp = client.post("/api/auth/pin", json={"pin": "0000"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_requires_auth(client: TestClient) -> None:
|
||||||
|
resp = client.get("/api/config/")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_authenticated(client: TestClient, auth_headers: dict) -> None:
|
||||||
|
resp = client.get("/api/config/", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["timezone"] == "Europe/Berlin"
|
||||||
|
assert any(s["id"] == "morning" for s in data["slots"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_today_and_log(client: TestClient, auth_headers: dict) -> None:
|
||||||
|
resp = client.get("/api/today", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
today = resp.json()
|
||||||
|
assert today["slots"][0]["id"] == "morning"
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/log",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"slot_id": "morning", "status": "taken"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
assert data["stats"]["total_taken"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_unknown_slot(client: TestClient, auth_headers: dict) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/log",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"slot_id": "nope", "status": "taken"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_snooze(client: TestClient, auth_headers: dict) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/snooze",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"slot_id": "morning", "minutes": 15},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "snooze_until" in resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_snooze_invalid_minutes(client: TestClient, auth_headers: dict) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/snooze",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"slot_id": "morning", "minutes": 42},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_api_disabled_without_token(client: TestClient) -> None:
|
||||||
|
import server.settings as settings_module
|
||||||
|
|
||||||
|
original = settings_module.settings.agent_token
|
||||||
|
try:
|
||||||
|
settings_module.settings.agent_token = ""
|
||||||
|
resp = client.get("/agent/health", headers={"Authorization": "Bearer foo"})
|
||||||
|
assert resp.status_code == 503
|
||||||
|
finally:
|
||||||
|
settings_module.settings.agent_token = original
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_health(client: TestClient, agent_headers: dict) -> None:
|
||||||
|
resp = client.get("/agent/health", headers=agent_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert "db_version" in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_status(client: TestClient, agent_headers: dict) -> None:
|
||||||
|
resp = client.get("/agent/status", headers=agent_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "today" in resp.json()
|
||||||
|
assert "stats" in resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_log_and_history(client: TestClient, agent_headers: dict) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/agent/log",
|
||||||
|
headers=agent_headers,
|
||||||
|
json={"slot_id": "morning", "status": "taken", "source": "agent"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "taken"
|
||||||
|
|
||||||
|
resp = client.get("/agent/history?days=7", headers=agent_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.json()["days"]) == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_config(client: TestClient, agent_headers: dict) -> None:
|
||||||
|
resp = client.get("/agent/config", headers=agent_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["timezone"] == "Europe/Berlin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_vapid_legacy_path(client: TestClient) -> None:
|
||||||
|
resp = client.get("/api/vapid-public-key")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "key" in resp.json()
|
||||||
Executable
+88
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Backup the SQLite database used by TakeYourMeds.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python tools/backup_db.py [destination_dir]
|
||||||
|
|
||||||
|
Defaults to ./data/backups/ and keeps the last 14 backups.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import gzip
|
||||||
|
import logging
|
||||||
|
import pathlib
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from server.settings import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
||||||
|
|
||||||
|
DEFAULT_KEEP = 14
|
||||||
|
|
||||||
|
|
||||||
|
def backup_db(destination: pathlib.Path | None = None, keep: int = DEFAULT_KEEP) -> pathlib.Path:
|
||||||
|
src = pathlib.Path(settings.db_path)
|
||||||
|
if not src.exists():
|
||||||
|
raise FileNotFoundError(f"Database not found: {src}")
|
||||||
|
|
||||||
|
dest_dir = destination or pathlib.Path(src.parent) / "backups"
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
dest_file = dest_dir / f"medis-{timestamp}.sqlite.gz"
|
||||||
|
|
||||||
|
# SQLite backup via online copy is safe because WAL is not enabled by default.
|
||||||
|
# For guaranteed consistency under writes, use the backup API.
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
async def _copy() -> None:
|
||||||
|
async with (
|
||||||
|
aiosqlite.connect(str(src)) as src_db,
|
||||||
|
aiosqlite.connect(str(dest_file).replace(".gz", "")) as dst_db,
|
||||||
|
):
|
||||||
|
await src_db.backup(dst_db)
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
plain = dest_file.with_suffix("")
|
||||||
|
asyncio.run(_copy())
|
||||||
|
|
||||||
|
with open(plain, "rb") as f_in, gzip.open(dest_file, "wb") as f_out:
|
||||||
|
shutil.copyfileobj(f_in, f_out)
|
||||||
|
plain.unlink()
|
||||||
|
|
||||||
|
logger.info("Backup created: %s", dest_file)
|
||||||
|
|
||||||
|
_rotate(dest_dir, keep)
|
||||||
|
return dest_file
|
||||||
|
|
||||||
|
|
||||||
|
def _rotate(dest_dir: pathlib.Path, keep: int) -> None:
|
||||||
|
backups = sorted(dest_dir.glob("medis-*.sqlite.gz"))
|
||||||
|
if len(backups) > keep:
|
||||||
|
for old in backups[:-keep]:
|
||||||
|
old.unlink()
|
||||||
|
logger.info("Removed old backup: %s", old.name)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Backup TakeYourMeds SQLite database")
|
||||||
|
parser.add_argument("destination", nargs="?", help="Backup directory (default: ./data/backups)")
|
||||||
|
parser.add_argument("--keep", type=int, default=DEFAULT_KEEP, help=f"Number of backups to keep (default: {DEFAULT_KEEP})")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
dest = pathlib.Path(args.destination) if args.destination else None
|
||||||
|
try:
|
||||||
|
backup_db(dest, keep=args.keep)
|
||||||
|
return 0
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Backup failed: %s", exc)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
-- Migration 001: initial schema
|
||||||
|
-- Idempotent via IF NOT EXISTS so it safely runs against existing databases.
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS intake_log (
|
CREATE TABLE IF NOT EXISTS intake_log (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
slot_id TEXT NOT NULL,
|
slot_id TEXT NOT NULL,
|
||||||
Reference in New Issue
Block a user