diff --git a/.env.example b/.env.example index c2d0192..1a106da 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,10 @@ APP_PIN=1234 JWT_SECRET=change-me-to-a-long-random-string 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_PRIVATE_KEY= VAPID_PUBLIC_KEY= diff --git a/.gitignore b/.gitignore index a669939..77dadc5 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ venv/ *.egg-info/ public/assets/* !public/assets/.gitkeep +tests/test.sqlite +tests/fixtures/ diff --git a/Dockerfile b/Dockerfile index 3382e66..f658484 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,4 +17,7 @@ COPY meds.yaml messages.yaml /app/ 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"] diff --git a/README.md b/README.md index 5ebb04e..d94fe6b 100644 --- a/README.md +++ b/README.md @@ -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 - **Einnahme loggen:** genommen / verpasst / snooze - **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 +- **DB-Migrationen** — versionierte SQL-Skripte für deploymentsichere Schema-Updates ### Spaß - **Dopamin-Drop** — Animation + Erfolgsspruch beim Loggen @@ -78,7 +80,7 @@ App: http://localhost:8000 ```bash 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 ``` @@ -86,9 +88,22 @@ docker compose up -d --build Traefik routet `medis.schwenk.online` → Container Port 8000 (TLS via `myresolver`). Volumes: -- `./data` — SQLite-Datenbank +- `./data` — SQLite-Datenbank + automatische Backups (`data/backups/`) - `./meds.yaml` — Medikamenten-Zeitplan (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) | | `JWT_SECRET` | Geheimer Key für JWT (min. 32 Zeichen empfohlen: `openssl rand -hex 32`) | | `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_PUBLIC_KEY` | Web-Push Public Key | | `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 -Alle Endpunkte unter `/api/*`. Auth via `Authorization: Bearer ` (außer `/api/auth/pin` und `/api/vapid-public-key`). +Alle Endpunkte unter `/api/*`. Auth via `Authorization: Bearer ` (außer `/api/auth/pin`, `/api/health/` und `/api/vapid-public-key`). | Methode | Pfad | Beschreibung | |---------|------|--------------| +| GET | `/api/health/` | Liveness + App-Version | | 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/today` | Heutige Slots + Status | | POST | `/api/log` | `{ "slot_id", "status", "day?", "source?" }` | @@ -146,8 +163,24 @@ Alle Endpunkte unter `/api/*`. Auth via `Authorization: Bearer ` (außer | GET | `/api/history?days=90` | Heatmap-Daten + Stats | | GET | `/api/stats` | Streak, Compliance, Meilensteine | | GET | `/api/roast` | Roast-of-the-Day | +| GET | `/api/motivation` | Tägliche Motivation | | 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 `. + +| 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 @@ -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 | | **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 | +| **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/ +├── 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) +├── public/ # Build-Output (index.html, sw.js, assets/) +├── tools/ # migrations/, gen_vapid.py, build_frontend.py, backup_db.py ├── compose.yml # Docker + Traefik ├── Dockerfile ├── meds.yaml # Medikamenten-Zeitplan ├── messages.yaml # Humor-Pool ├── icon.png / icon.svg # App-Icons (Quelle) -├── server/ # FastAPI Backend -├── public/ # Build-Output (index.html, sw.js, assets/) -├── tools/ # schema.sql, gen_vapid.py, build_frontend.py -└── data/ # SQLite (gitignored) +└── data/ # SQLite + Backups (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. +### Tests + +```bash +python -m pytest tests/ -v +``` + --- ## Troubleshooting diff --git a/compose.yml b/compose.yml index b70a315..bf858ad 100644 --- a/compose.yml +++ b/compose.yml @@ -6,6 +6,7 @@ services: - APP_PIN=${APP_PIN} - JWT_SECRET=${JWT_SECRET} - JWT_EXPIRE_DAYS=${JWT_EXPIRE_DAYS:-90} + - AGENT_TOKEN=${AGENT_TOKEN:-} - VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY} - VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY} - VAPID_CLAIMS_EMAIL=${VAPID_CLAIMS_EMAIL:-mailto:admin@schwenk.online} diff --git a/public/sw.js b/public/sw.js index 7e5c7e2..9954fd3 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE = "tym-v1.1.0-914619f3"; +const CACHE = "tym-v1.2.0-914619f3"; const ASSETS = [ "/", "/index.html", diff --git a/public/version.json b/public/version.json index 0183054..f17d1ac 100644 --- a/public/version.json +++ b/public/version.json @@ -1,5 +1,5 @@ { - "version": "1.1.0", - "buildTime": "2026-07-08T06:50:31.880953Z", + "version": "1.2.0", + "buildTime": "2026-09-21T10:33:11.129661Z", "buildHash": "914619f3" } diff --git a/pyproject.toml b/pyproject.toml index 1ed11a5..50ca417 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "takeyourmeds" -version = "1.1.0" -description = "Medikamenten-PWA mit Push-Erinnerungen und sarkastischem Humor" +version = "1.2.0" +description = "Medikamenten-PWA mit Push-Erinnerungen, Agent-API und sarkastischem Humor" requires-python = ">=3.11" dependencies = [ "fastapi>=0.115", @@ -16,6 +16,13 @@ dependencies = [ "apscheduler>=3.10", ] +[project.optional-dependencies] +test = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", + "httpx>=0.27", +] + [build-system] requires = ["setuptools>=70", "wheel"] build-backend = "setuptools.build_meta" diff --git a/server/ai.py b/server/ai.py index 85c82d7..2e679d8 100644 --- a/server/ai.py +++ b/server/ai.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from typing import Any from zoneinfo import ZoneInfo @@ -105,7 +105,7 @@ async def _recent_roasts( before_day: str, days: int = ROAST_HISTORY_DAYS, ) -> 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] = [] for offset in range(1, days + 1): day = (end - timedelta(days=offset)).isoformat() diff --git a/server/api/__init__.py b/server/api/__init__.py new file mode 100644 index 0000000..eac8584 --- /dev/null +++ b/server/api/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from fastapi import APIRouter + +router = APIRouter(prefix="/api/auth", tags=["auth"]) diff --git a/server/api/agent.py b/server/api/agent.py new file mode 100644 index 0000000..f43fdc5 --- /dev/null +++ b/server/api/agent.py @@ -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) diff --git a/server/api/auth.py b/server/api/auth.py new file mode 100644 index 0000000..121a0aa --- /dev/null +++ b/server/api/auth.py @@ -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) diff --git a/server/api/config.py b/server/api/config.py new file mode 100644 index 0000000..151755c --- /dev/null +++ b/server/api/config.py @@ -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, + ) diff --git a/server/api/content.py b/server/api/content.py new file mode 100644 index 0000000..bf71b4d --- /dev/null +++ b/server/api/content.py @@ -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) diff --git a/server/api/health.py b/server/api/health.py new file mode 100644 index 0000000..07b6763 --- /dev/null +++ b/server/api/health.py @@ -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, + ) diff --git a/server/api/intake.py b/server/api/intake.py new file mode 100644 index 0000000..f7e5c00 --- /dev/null +++ b/server/api/intake.py @@ -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, + ) diff --git a/server/api/push.py b/server/api/push.py new file mode 100644 index 0000000..cd649e1 --- /dev/null +++ b/server/api/push.py @@ -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) diff --git a/server/api/stats.py b/server/api/stats.py new file mode 100644 index 0000000..3dfba22 --- /dev/null +++ b/server/api/stats.py @@ -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) diff --git a/server/app.py b/server/app.py index 649d5f3..5f57846 100644 --- a/server/app.py +++ b/server/app.py @@ -4,24 +4,18 @@ import asyncio import logging import pathlib from contextlib import asynccontextmanager -from datetime import datetime -from typing import Any -from fastapi import Depends, FastAPI, HTTPException, Request -from fastapi.responses import FileResponse, JSONResponse +from fastapi import FastAPI +from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles -from server.ai import generate_daily_motivation, generate_roast_of_the_day, get_roast_of_the_day -from server.auth import create_token, require_auth, verify_pin -from server.config_loader import load_meds_config, slot_to_dict, today_str -from server.db import ensure_schema, get_db, new_id, utc_now_iso -from server.messages import pick -from server.push import save_subscription, send_slot_reminder -from server.scheduler import schedule_snooze, start_scheduler +from server.ai import generate_daily_motivation, generate_roast_of_the_day +from server.api import agent, auth, config, content, health, intake, push, stats +from server.config_loader import load_meds_config +from server.db import ensure_schema, get_db +from server.scheduler import start_scheduler from server.settings import settings -from server.slots import build_today from server.static_cache import CacheControlMiddleware -from server.stats import build_history, check_milestones, compute_stats, MILESTONE_DEFS 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.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") -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) - - +# Backwards-compatible VAPID endpoint used by older clients @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) -@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 PUBLIC = pathlib.Path(settings.public_dir) if PUBLIC.exists(): diff --git a/server/auth.py b/server/auth.py index e21d195..ad12294 100644 --- a/server/auth.py +++ b/server/auth.py @@ -1,11 +1,12 @@ from __future__ import annotations import hmac -from datetime import datetime, timedelta, timezone +import secrets +from datetime import UTC, datetime, timedelta from typing import Any import jwt -from fastapi import Depends, HTTPException, Request +from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from server.settings import settings @@ -19,7 +20,7 @@ def verify_pin(pin: str) -> bool: 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} 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": raise HTTPException(status_code=401, detail="Missing token") 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"} diff --git a/server/db.py b/server/db.py index b5ea68e..b82c2cc 100644 --- a/server/db.py +++ b/server/db.py @@ -2,21 +2,19 @@ from __future__ import annotations import pathlib import uuid -from datetime import datetime, timezone +from datetime import UTC, datetime import aiosqlite from server.settings import settings -SCHEMA_PATH = str(pathlib.Path(__file__).resolve().parents[1] / "tools" / "schema.sql") - def new_id() -> str: return str(uuid.uuid4()) 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: @@ -27,6 +25,6 @@ async def get_db() -> aiosqlite.Connection: async def ensure_schema(db: aiosqlite.Connection) -> None: - schema = pathlib.Path(SCHEMA_PATH).read_text(encoding="utf-8") - await db.executescript(schema) - await db.commit() + from server.migrations import run_migrations + + await run_migrations(db) diff --git a/server/migrations.py b/server/migrations.py new file mode 100644 index 0000000..f57868f --- /dev/null +++ b/server/migrations.py @@ -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)) diff --git a/server/scheduler.py b/server/scheduler.py index b8093a9..683c94b 100644 --- a/server/scheduler.py +++ b/server/scheduler.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio import logging from datetime import datetime, timedelta @@ -10,11 +9,11 @@ from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.date import DateTrigger 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.db import get_db, utc_now_iso +from server.config_loader import load_meds_config, today_str +from server.db import get_db from server.messages import pick 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__) scheduler = AsyncIOScheduler() diff --git a/server/schemas.py b/server/schemas.py new file mode 100644 index 0000000..7791837 --- /dev/null +++ b/server/schemas.py @@ -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 diff --git a/server/settings.py b/server/settings.py index 42173c2..8b133bc 100644 --- a/server/settings.py +++ b/server/settings.py @@ -10,6 +10,8 @@ class Settings(BaseSettings): jwt_secret: str = "dev-secret-change-me" jwt_expire_days: int = 90 + agent_token: str = "" # long-lived token for /agent/* endpoints + vapid_private_key: str = "" vapid_public_key: str = "" vapid_claims_email: str = "mailto:admin@schwenk.online" @@ -22,7 +24,8 @@ class Settings(BaseSettings): messages_path: str = "messages.yaml" 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] diff --git a/server/slots.py b/server/slots.py index ec8fbb6..788aa17 100644 --- a/server/slots.py +++ b/server/slots.py @@ -6,7 +6,13 @@ from zoneinfo import ZoneInfo 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 diff --git a/server/stats.py b/server/stats.py index 44d3474..8c6aec6 100644 --- a/server/stats.py +++ b/server/stats.py @@ -7,7 +7,7 @@ from zoneinfo import ZoneInfo import aiosqlite 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 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7e422f0 --- /dev/null +++ b/tests/conftest.py @@ -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"} diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..dea3ba6 --- /dev/null +++ b/tests/test_api.py @@ -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() diff --git a/tools/backup_db.py b/tools/backup_db.py new file mode 100755 index 0000000..89ca56b --- /dev/null +++ b/tools/backup_db.py @@ -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()) diff --git a/tools/schema.sql b/tools/migrations/001_initial.sql similarity index 88% rename from tools/schema.sql rename to tools/migrations/001_initial.sql index 7c91e9e..e86e5ff 100644 --- a/tools/schema.sql +++ b/tools/migrations/001_initial.sql @@ -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 ( id TEXT PRIMARY KEY, slot_id TEXT NOT NULL,