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:
+2
-2
@@ -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()
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user