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:
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())
|
||||
Reference in New Issue
Block a user