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