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:
Frank Schwenk
2026-09-21 12:46:44 +02:00
parent 7cec3030c1
commit 88bdf6f34b
31 changed files with 942 additions and 184 deletions
+88
View File
@@ -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 (
id TEXT PRIMARY KEY,
slot_id TEXT NOT NULL,