Files
takeyourmeds/server/app.py
T
Frank Schwenk 7cec3030c1 feat: PWA update strategy with hashed assets and cache control
Move frontend sources to frontend/, build content-hashed assets at deploy,
network-first SW for shell/JS, immutable cache for /assets/, version.json
update banner, and Cache-Control middleware. Docs in docs/PWA-STRATEGY.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 08:51:12 +02:00

217 lines
6.9 KiB
Python

from __future__ import annotations
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.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.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"}
def _configure_logging() -> None:
fmt = logging.Formatter("%(levelname)s: %(message)s")
server_log = logging.getLogger("server")
if not server_log.handlers:
handler = logging.StreamHandler()
handler.setFormatter(fmt)
server_log.addHandler(handler)
server_log.setLevel(logging.INFO)
server_log.propagate = False
_configure_logging()
logger = logging.getLogger(__name__)
async def _ensure_today_ai_texts() -> None:
config = load_meds_config()
db = await get_db()
try:
await generate_daily_motivation(db, config.timezone)
await generate_roast_of_the_day(db, config.timezone)
except Exception:
logger.exception("Startup AI text generation failed")
finally:
await db.close()
@asynccontextmanager
async def lifespan(app: FastAPI):
db = await get_db()
await ensure_schema(db)
await db.close()
start_scheduler()
asyncio.create_task(_ensure_today_ai_texts())
yield
app = FastAPI(title="TakeYourMeds", version=settings.app_version, lifespan=lifespan)
app.add_middleware(CacheControlMiddleware)
@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)
@app.get("/api/vapid-public-key")
async def vapid_public_key() -> 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():
app.mount("/", StaticFiles(directory=str(PUBLIC), html=True), name="static")