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
+2 -2
View File
@@ -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()
+5
View File
@@ -0,0 +1,5 @@
from __future__ import annotations
from fastapi import APIRouter
router = APIRouter(prefix="/api/auth", tags=["auth"])
+114
View File
@@ -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)
+22
View File
@@ -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)
+25
View File
@@ -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,
)
+35
View File
@@ -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)
+18
View File
@@ -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,
)
+92
View File
@@ -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,
)
+32
View File
@@ -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)
+22
View File
@@ -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
View File
@@ -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():
+21 -3
View File
@@ -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"}
+5 -7
View File
@@ -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)
+79
View File
@@ -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
View File
@@ -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()
+55
View File
@@ -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
View File
@@ -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]
+7 -1
View File
@@ -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
+1 -1
View File
@@ -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