debug line test
This commit is contained in:
+103
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import aiosqlite
|
||||
import httpx
|
||||
|
||||
from server.db import utc_now_iso
|
||||
from server.messages import pick
|
||||
from server.settings import settings
|
||||
from server.stats import compute_stats
|
||||
|
||||
|
||||
async def _get_cache(db: aiosqlite.Connection, key: str) -> str | None:
|
||||
cur = await db.execute("SELECT content FROM ai_cache WHERE key = ?", (key,))
|
||||
row = await cur.fetchone()
|
||||
await cur.close()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
async def _set_cache(db: aiosqlite.Connection, key: str, content: str) -> None:
|
||||
await db.execute(
|
||||
"INSERT INTO ai_cache (key, content, created_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET content=excluded.content, created_at=excluded.created_at",
|
||||
(key, content, utc_now_iso()),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _call_openrouter(prompt: str) -> str | None:
|
||||
if not settings.openrouter_api_key:
|
||||
return None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
||||
"HTTP-Referer": "https://medis.schwenk.online",
|
||||
"X-Title": "TakeYourMeds",
|
||||
},
|
||||
json={
|
||||
"model": settings.openrouter_model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Du bist ein sarkastischer, dark-humor Medikamenten-Coach für jemanden mit ADHS. Kurz (max 2 Sätze), deutsch, witzig aber nicht gemein. Keine medizinischen Ratschläge.",
|
||||
},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"max_tokens": 120,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data["choices"][0]["message"]["content"].strip()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def get_roast_of_the_day(db: aiosqlite.Connection, tz: ZoneInfo) -> dict[str, Any]:
|
||||
day = datetime.now(tz).strftime("%Y-%m-%d")
|
||||
key = f"roast:{day}"
|
||||
cached = await _get_cache(db, key)
|
||||
if cached:
|
||||
return {"text": cached, "source": "cache", "day": day}
|
||||
|
||||
stats = await compute_stats(db)
|
||||
prompt = f"Roast-of-the-Day für jemanden mit ADHS. Streak: {stats['streak']} Tage, Compliance 90d: {stats['compliance_percent']}%. Ein sarkastischer Spruch."
|
||||
text = await _call_openrouter(prompt)
|
||||
if not text:
|
||||
text = pick("roast_fallback")
|
||||
source = "fallback"
|
||||
else:
|
||||
source = "ai"
|
||||
await _set_cache(db, key, text)
|
||||
|
||||
return {"text": text, "source": source, "day": day}
|
||||
|
||||
|
||||
async def get_oracle(db: aiosqlite.Connection, tz: ZoneInfo) -> dict[str, Any]:
|
||||
week = datetime.now(tz).strftime("%Y-W%W")
|
||||
key = f"oracle:{week}"
|
||||
cached = await _get_cache(db, key)
|
||||
if cached:
|
||||
return {"text": cached, "source": "cache", "week": week}
|
||||
|
||||
stats = await compute_stats(db)
|
||||
prompt = (
|
||||
f"Wöchentlicher KI-Orakel-Report (passiv-aggressiv, max 4 Sätze). "
|
||||
f"Streak: {stats['streak']}, Compliance 90d: {stats['compliance_percent']}%, "
|
||||
f"genommen gesamt: {stats['total_taken']}."
|
||||
)
|
||||
text = await _call_openrouter(prompt)
|
||||
if not text:
|
||||
text = pick("streak", streak=stats["streak"]) + f" Compliance: {stats['compliance_percent']}%."
|
||||
source = "fallback"
|
||||
else:
|
||||
source = "ai"
|
||||
await _set_cache(db, key, text)
|
||||
|
||||
return {"text": text, "source": source, "week": week}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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 get_oracle, 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.stats import build_history, check_milestones, compute_stats, MILESTONE_DEFS
|
||||
|
||||
NO_STORE = {"Cache-Control": "no-store"}
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
db = await get_db()
|
||||
await ensure_schema(db)
|
||||
await db.close()
|
||||
start_scheduler()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="TakeYourMeds", version=settings.app_version, lifespan=lifespan)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@app.get("/api/oracle")
|
||||
async def get_oracle_route(_: dict = Depends(require_auth)) -> JSONResponse:
|
||||
config = load_meds_config()
|
||||
db = await get_db()
|
||||
try:
|
||||
data = await get_oracle(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")
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from server.settings import settings
|
||||
|
||||
_bearer = HTTPBearer(auto_error=False)
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
def verify_pin(pin: str) -> bool:
|
||||
return hmac.compare_digest(pin.strip(), settings.app_pin.strip())
|
||||
|
||||
|
||||
def create_token() -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(days=settings.jwt_expire_days)
|
||||
payload = {"sub": "user", "exp": expire}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict[str, Any]:
|
||||
try:
|
||||
return jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM])
|
||||
except jwt.PyJWTError as exc:
|
||||
raise HTTPException(status_code=401, detail="Invalid token") from exc
|
||||
|
||||
|
||||
async def require_auth(
|
||||
creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||
) -> dict[str, Any]:
|
||||
if creds is None or creds.scheme.lower() != "bearer":
|
||||
raise HTTPException(status_code=401, detail="Missing token")
|
||||
return decode_token(creds.credentials)
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, time
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import yaml
|
||||
|
||||
from server.settings import settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class Med:
|
||||
name: str
|
||||
dose: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Slot:
|
||||
id: str
|
||||
time: time
|
||||
label: str
|
||||
meds: list[Med]
|
||||
reminder_window_minutes: int = 90
|
||||
|
||||
|
||||
@dataclass
|
||||
class MedsConfig:
|
||||
timezone: ZoneInfo
|
||||
slots: list[Slot] = field(default_factory=list)
|
||||
|
||||
|
||||
_config: MedsConfig | None = None
|
||||
|
||||
|
||||
def load_meds_config(path: str | None = None) -> MedsConfig:
|
||||
global _config
|
||||
if _config is not None:
|
||||
return _config
|
||||
p = pathlib.Path(path or settings.meds_path)
|
||||
raw = yaml.safe_load(p.read_text(encoding="utf-8"))
|
||||
tz = ZoneInfo(raw.get("timezone", "Europe/Berlin"))
|
||||
slots: list[Slot] = []
|
||||
for item in raw.get("slots", []):
|
||||
h, m = str(item["time"]).split(":")
|
||||
slots.append(
|
||||
Slot(
|
||||
id=item["id"],
|
||||
time=time(int(h), int(m)),
|
||||
label=item.get("label", item["id"]),
|
||||
meds=[Med(**m) for m in item.get("meds", [])],
|
||||
reminder_window_minutes=int(item.get("reminder_window_minutes", 90)),
|
||||
)
|
||||
)
|
||||
_config = MedsConfig(timezone=tz, slots=slots)
|
||||
return _config
|
||||
|
||||
|
||||
def reload_meds_config() -> MedsConfig:
|
||||
global _config
|
||||
_config = None
|
||||
return load_meds_config()
|
||||
|
||||
|
||||
def slot_datetime(day: datetime, slot: Slot, tz: ZoneInfo) -> datetime:
|
||||
local = day.astimezone(tz) if day.tzinfo else day.replace(tzinfo=tz)
|
||||
return local.replace(hour=slot.time.hour, minute=slot.time.minute, second=0, microsecond=0)
|
||||
|
||||
|
||||
def today_str(tz: ZoneInfo) -> str:
|
||||
return datetime.now(tz).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def slot_to_dict(slot: Slot) -> dict[str, Any]:
|
||||
return {
|
||||
"id": slot.id,
|
||||
"time": slot.time.strftime("%H:%M"),
|
||||
"label": slot.label,
|
||||
"meds": [{"name": m.name, "dose": m.dose} for m in slot.meds],
|
||||
"reminder_window_minutes": slot.reminder_window_minutes,
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
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()
|
||||
|
||||
|
||||
async def get_db() -> aiosqlite.Connection:
|
||||
pathlib.Path(settings.db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
db = await aiosqlite.connect(settings.db_path)
|
||||
await db.execute("PRAGMA foreign_keys = ON;")
|
||||
return db
|
||||
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from server.settings import settings
|
||||
|
||||
_pool: dict[str, list[str]] | None = None
|
||||
|
||||
DEFAULT_MESSAGES: dict[str, list[str]] = {
|
||||
"reminder": ["Zeit für Medis. Dein Gehirn wartet.", "Med-Time. Nicht vergessen."],
|
||||
"success": ["Genommen. Held des Tages.", "Dopamin incoming."],
|
||||
"missed": ["Verpasst. Das Gehirn ist enttäuscht.", "Nächstes Mal vielleicht."],
|
||||
"streak": ["Streak läuft!", "Weiter so, Ausnahme vom ADHS-Gesetz."],
|
||||
"snooze": ["Okay, noch 15 Min. Aber wirklich.", "Snooze aktiviert. Prokrastination approved."],
|
||||
"easter_egg": ["Achievement unlocked!", "Du hast was Seltenes freigeschaltet."],
|
||||
"roast_fallback": [
|
||||
"Dein Gehirn hat heute schon aufgegeben, bevor du die PIN eingegeben hast.",
|
||||
"Elvanse wartet. Du offenbar auch, aber im falschen Sinne.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def load_messages(path: str | None = None) -> dict[str, list[str]]:
|
||||
global _pool
|
||||
if _pool is not None:
|
||||
return _pool
|
||||
p = pathlib.Path(path or settings.messages_path)
|
||||
if p.exists():
|
||||
raw = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
|
||||
_pool = {**DEFAULT_MESSAGES, **{k: v for k, v in raw.items() if isinstance(v, list)}}
|
||||
else:
|
||||
_pool = DEFAULT_MESSAGES
|
||||
return _pool
|
||||
|
||||
|
||||
def pick(category: str, **fmt: Any) -> str:
|
||||
pool = load_messages()
|
||||
choices = pool.get(category) or pool.get("reminder", ["Medis."])
|
||||
text = random.choice(choices)
|
||||
if fmt:
|
||||
try:
|
||||
text = text.format(**fmt)
|
||||
except (KeyError, IndexError):
|
||||
pass
|
||||
return text
|
||||
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import aiosqlite
|
||||
from pywebpush import WebPushException, webpush
|
||||
|
||||
from server.db import utc_now_iso
|
||||
from server.settings import settings
|
||||
|
||||
|
||||
async def save_subscription(db: aiosqlite.Connection, sub: dict[str, Any]) -> None:
|
||||
keys = sub.get("keys", {})
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO push_subscription (endpoint, p256dh, auth, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(endpoint) DO UPDATE SET p256dh=excluded.p256dh, auth=excluded.auth
|
||||
""",
|
||||
(sub["endpoint"], keys.get("p256dh", ""), keys.get("auth", ""), utc_now_iso()),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_subscriptions(db: aiosqlite.Connection) -> list[dict[str, str]]:
|
||||
cur = await db.execute("SELECT endpoint, p256dh, auth FROM push_subscription")
|
||||
rows = await cur.fetchall()
|
||||
await cur.close()
|
||||
return [{"endpoint": r[0], "p256dh": r[1], "auth": r[2]} for r in rows]
|
||||
|
||||
|
||||
def _vapid_claims() -> dict[str, str]:
|
||||
return {"sub": settings.vapid_claims_email}
|
||||
|
||||
|
||||
async def send_push(
|
||||
db: aiosqlite.Connection,
|
||||
payload: dict[str, Any],
|
||||
) -> int:
|
||||
if not settings.vapid_private_key or not settings.vapid_public_key:
|
||||
return 0
|
||||
|
||||
subs = await get_subscriptions(db)
|
||||
sent = 0
|
||||
dead: list[str] = []
|
||||
|
||||
for sub in subs:
|
||||
subscription = {
|
||||
"endpoint": sub["endpoint"],
|
||||
"keys": {"p256dh": sub["p256dh"], "auth": sub["auth"]},
|
||||
}
|
||||
try:
|
||||
webpush(
|
||||
subscription_info=subscription,
|
||||
data=json.dumps(payload),
|
||||
vapid_private_key=settings.vapid_private_key,
|
||||
vapid_claims=_vapid_claims(),
|
||||
)
|
||||
sent += 1
|
||||
except WebPushException as exc:
|
||||
if exc.response and exc.response.status_code in (404, 410):
|
||||
dead.append(sub["endpoint"])
|
||||
|
||||
for endpoint in dead:
|
||||
await db.execute("DELETE FROM push_subscription WHERE endpoint = ?", (endpoint,))
|
||||
if dead:
|
||||
await db.commit()
|
||||
|
||||
return sent
|
||||
|
||||
|
||||
async def send_slot_reminder(
|
||||
db: aiosqlite.Connection,
|
||||
slot_id: str,
|
||||
title: str,
|
||||
body: str,
|
||||
*,
|
||||
silent: bool = True,
|
||||
) -> int:
|
||||
payload = {
|
||||
"title": title,
|
||||
"body": body,
|
||||
"slot_id": slot_id,
|
||||
"silent": silent,
|
||||
"tag": f"med-{slot_id}",
|
||||
}
|
||||
return await send_push(db, payload)
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.triggers.date import DateTrigger
|
||||
|
||||
from server.config_loader import load_meds_config, slot_to_dict, today_str
|
||||
from server.db import get_db, utc_now_iso
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
scheduler = AsyncIOScheduler()
|
||||
|
||||
|
||||
async def _remind_slot(slot_id: str) -> None:
|
||||
config = load_meds_config()
|
||||
tz = config.timezone
|
||||
day = today_str(tz)
|
||||
db = await get_db()
|
||||
try:
|
||||
logs = await get_log_for_day(db, day)
|
||||
if slot_id in logs and logs[slot_id]["status"] == "taken":
|
||||
return
|
||||
slot = next((s for s in config.slots if s.id == slot_id), None)
|
||||
if not slot:
|
||||
return
|
||||
title = f"{slot.label} — Med-Time!"
|
||||
body = pick("reminder", label=slot.label, med=slot.meds[0].name if slot.meds else "Medis")
|
||||
await send_slot_reminder(db, slot_id, title, body)
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def _evening_check() -> None:
|
||||
db = await get_db()
|
||||
try:
|
||||
marked = await mark_missed_for_overdue(db)
|
||||
for slot_id in marked:
|
||||
body = pick("missed")
|
||||
await send_slot_reminder(db, slot_id, "Verpasst?", body)
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def schedule_snooze(slot_id: str, minutes: int) -> str:
|
||||
config = load_meds_config()
|
||||
tz = config.timezone
|
||||
now = datetime.now(tz)
|
||||
until = now + timedelta(minutes=minutes)
|
||||
day = today_str(tz)
|
||||
|
||||
db = await get_db()
|
||||
try:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO snooze (slot_id, day, snooze_until) VALUES (?, ?, ?)
|
||||
ON CONFLICT(slot_id, day) DO UPDATE SET snooze_until=excluded.snooze_until
|
||||
""",
|
||||
(slot_id, day, until.isoformat()),
|
||||
)
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
run_at = until.astimezone(tz).replace(tzinfo=None)
|
||||
scheduler.add_job(
|
||||
_remind_slot,
|
||||
trigger=DateTrigger(run_date=run_at),
|
||||
args=[slot_id],
|
||||
id=f"snooze-{slot_id}-{until.timestamp()}",
|
||||
replace_existing=False,
|
||||
)
|
||||
return until.isoformat()
|
||||
|
||||
|
||||
def start_scheduler() -> None:
|
||||
config = load_meds_config()
|
||||
tz = config.timezone
|
||||
|
||||
for slot in config.slots:
|
||||
h, m = slot.time.hour, slot.time.minute
|
||||
scheduler.add_job(
|
||||
_remind_slot,
|
||||
trigger=CronTrigger(hour=h, minute=m, timezone=tz),
|
||||
args=[slot.id],
|
||||
id=f"remind-{slot.id}",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
scheduler.add_job(
|
||||
_evening_check,
|
||||
trigger=CronTrigger(hour=21, minute=0, timezone=tz),
|
||||
id="evening-check",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
if not scheduler.running:
|
||||
scheduler.start()
|
||||
logger.info("Scheduler started for timezone %s", tz)
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
app_pin: str = "1234"
|
||||
jwt_secret: str = "dev-secret-change-me"
|
||||
jwt_expire_days: int = 90
|
||||
|
||||
vapid_private_key: str = ""
|
||||
vapid_public_key: str = ""
|
||||
vapid_claims_email: str = "mailto:admin@schwenk.online"
|
||||
|
||||
openrouter_api_key: str = ""
|
||||
openrouter_model: str = "google/gemini-2.0-flash-001"
|
||||
|
||||
db_path: str = "data/medis.sqlite"
|
||||
meds_path: str = "meds.yaml"
|
||||
messages_path: str = "messages.yaml"
|
||||
|
||||
public_dir: str = "public"
|
||||
app_version: str = "1.0.0"
|
||||
|
||||
|
||||
settings = Settings() # type: ignore[call-arg]
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from server.config_loader import MedsConfig, Slot, load_meds_config, slot_datetime, today_str
|
||||
from server.db import utc_now_iso
|
||||
|
||||
|
||||
async def get_log_for_day(db: aiosqlite.Connection, day: str) -> dict[str, dict[str, Any]]:
|
||||
cur = await db.execute(
|
||||
"SELECT slot_id, status, logged_at, source FROM intake_log WHERE day = ? ORDER BY logged_at DESC",
|
||||
(day,),
|
||||
)
|
||||
rows = await cur.fetchall()
|
||||
await cur.close()
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for slot_id, status, logged_at, source in rows:
|
||||
if slot_id not in result:
|
||||
result[slot_id] = {"status": status, "logged_at": logged_at, "source": source}
|
||||
return result
|
||||
|
||||
|
||||
async def get_snooze_for_day(db: aiosqlite.Connection, day: str) -> dict[str, str]:
|
||||
cur = await db.execute("SELECT slot_id, snooze_until FROM snooze WHERE day = ?", (day,))
|
||||
rows = await cur.fetchall()
|
||||
await cur.close()
|
||||
return {slot_id: snooze_until for slot_id, snooze_until in rows}
|
||||
|
||||
|
||||
def compute_status(
|
||||
slot: Slot,
|
||||
now: datetime,
|
||||
tz: ZoneInfo,
|
||||
log_entry: dict[str, Any] | None,
|
||||
snooze_until: str | None,
|
||||
) -> str:
|
||||
if log_entry:
|
||||
return log_entry["status"]
|
||||
|
||||
slot_dt = slot_datetime(now, slot, tz)
|
||||
window_end = slot_dt + timedelta(minutes=slot.reminder_window_minutes)
|
||||
|
||||
if snooze_until:
|
||||
try:
|
||||
snooze_dt = datetime.fromisoformat(snooze_until)
|
||||
if now < snooze_dt:
|
||||
return "snoozed"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if now < slot_dt:
|
||||
return "upcoming"
|
||||
|
||||
if now <= window_end:
|
||||
return "pending"
|
||||
|
||||
return "overdue"
|
||||
|
||||
|
||||
async def build_today(db: aiosqlite.Connection, config: MedsConfig | None = None) -> dict[str, Any]:
|
||||
config = config or load_meds_config()
|
||||
tz = config.timezone
|
||||
now = datetime.now(tz)
|
||||
day = today_str(tz)
|
||||
logs = await get_log_for_day(db, day)
|
||||
snoozes = await get_snooze_for_day(db, day)
|
||||
|
||||
slots_out: list[dict[str, Any]] = []
|
||||
for slot in config.slots:
|
||||
log_entry = logs.get(slot.id)
|
||||
snooze_until = snoozes.get(slot.id)
|
||||
status = compute_status(slot, now, tz, log_entry, snooze_until)
|
||||
slot_dt = slot_datetime(now, slot, tz)
|
||||
window_end = slot_dt + timedelta(minutes=slot.reminder_window_minutes)
|
||||
slots_out.append(
|
||||
{
|
||||
"id": slot.id,
|
||||
"time": slot.time.strftime("%H:%M"),
|
||||
"label": slot.label,
|
||||
"meds": [{"name": m.name, "dose": m.dose} for m in slot.meds],
|
||||
"status": status,
|
||||
"logged_at": log_entry["logged_at"] if log_entry else None,
|
||||
"window_end": window_end.isoformat(),
|
||||
"snooze_until": snooze_until,
|
||||
}
|
||||
)
|
||||
|
||||
return {"day": day, "timezone": str(tz), "now": now.isoformat(), "slots": slots_out}
|
||||
|
||||
|
||||
async def mark_missed_for_overdue(db: aiosqlite.Connection, config: MedsConfig | None = None) -> list[str]:
|
||||
from server.db import new_id
|
||||
|
||||
config = config or load_meds_config()
|
||||
tz = config.timezone
|
||||
now = datetime.now(tz)
|
||||
day = today_str(tz)
|
||||
logs = await get_log_for_day(db, day)
|
||||
marked: list[str] = []
|
||||
|
||||
for slot in config.slots:
|
||||
if slot.id in logs:
|
||||
continue
|
||||
status = compute_status(slot, now, tz, None, None)
|
||||
if status == "overdue":
|
||||
await db.execute(
|
||||
"INSERT INTO intake_log (id, slot_id, day, status, logged_at, source) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(new_id(), slot.id, day, "missed", utc_now_iso(), "system"),
|
||||
)
|
||||
marked.append(slot.id)
|
||||
|
||||
if marked:
|
||||
await db.commit()
|
||||
return marked
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
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
|
||||
|
||||
MILESTONE_DEFS: dict[str, str] = {
|
||||
"log_50": "Pharma-Intern: 50 Logs. Dein Arzt wäre stolz. Vielleicht.",
|
||||
"log_100": "Century Club: 100 Logs. Du bist offiziell zuverlässiger als dein WLAN.",
|
||||
"streak_7": "Wochenkrieger: 7 Tage Streak. ADHS.exe has stopped crashing.",
|
||||
"streak_30": "Monatslegende: 30 Tage. Das ist fast schon verdächtig diszipliniert.",
|
||||
"streak_100": "Unmöglichkeitsgrad: 100 Tage Streak. Cheater oder Heilung?",
|
||||
}
|
||||
|
||||
|
||||
async def count_logs(db: aiosqlite.Connection) -> int:
|
||||
cur = await db.execute("SELECT COUNT(*) FROM intake_log WHERE status = 'taken'")
|
||||
row = await cur.fetchone()
|
||||
await cur.close()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
|
||||
async def get_milestones(db: aiosqlite.Connection) -> list[dict[str, str]]:
|
||||
cur = await db.execute("SELECT id, unlocked_at FROM milestones ORDER BY unlocked_at")
|
||||
rows = await cur.fetchall()
|
||||
await cur.close()
|
||||
return [{"id": r[0], "title": MILESTONE_DEFS.get(r[0], r[0]), "unlocked_at": r[1]} for r in rows]
|
||||
|
||||
|
||||
async def unlock_milestone(db: aiosqlite.Connection, milestone_id: str) -> bool:
|
||||
cur = await db.execute("SELECT 1 FROM milestones WHERE id = ?", (milestone_id,))
|
||||
exists = await cur.fetchone()
|
||||
await cur.close()
|
||||
if exists:
|
||||
return False
|
||||
await db.execute(
|
||||
"INSERT INTO milestones (id, unlocked_at) VALUES (?, ?)",
|
||||
(milestone_id, utc_now_iso()),
|
||||
)
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def check_milestones(db: aiosqlite.Connection, streak: int) -> list[str]:
|
||||
new: list[str] = []
|
||||
total = await count_logs(db)
|
||||
checks = []
|
||||
if total >= 50:
|
||||
checks.append("log_50")
|
||||
if total >= 100:
|
||||
checks.append("log_100")
|
||||
if streak >= 7:
|
||||
checks.append("streak_7")
|
||||
if streak >= 30:
|
||||
checks.append("streak_30")
|
||||
if streak >= 100:
|
||||
checks.append("streak_100")
|
||||
for mid in checks:
|
||||
if await unlock_milestone(db, mid):
|
||||
new.append(mid)
|
||||
return new
|
||||
|
||||
|
||||
async def day_compliance(db: aiosqlite.Connection, day: str, slot_ids: list[str]) -> bool:
|
||||
if not slot_ids:
|
||||
return True
|
||||
placeholders = ",".join("?" * len(slot_ids))
|
||||
cur = await db.execute(
|
||||
f"SELECT COUNT(DISTINCT slot_id) FROM intake_log WHERE day = ? AND status = 'taken' AND slot_id IN ({placeholders})",
|
||||
(day, *slot_ids),
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
await cur.close()
|
||||
return int(row[0]) >= len(slot_ids) if row else False
|
||||
|
||||
|
||||
async def compute_streak(db: aiosqlite.Connection, tz: ZoneInfo) -> int:
|
||||
config = load_meds_config()
|
||||
slot_ids = [s.id for s in config.slots]
|
||||
streak = 0
|
||||
day = datetime.now(tz).date()
|
||||
while True:
|
||||
day_str = day.isoformat()
|
||||
ok = await day_compliance(db, day_str, slot_ids)
|
||||
if not ok:
|
||||
break
|
||||
streak += 1
|
||||
day -= timedelta(days=1)
|
||||
return streak
|
||||
|
||||
|
||||
async def compute_stats(db: aiosqlite.Connection) -> dict[str, Any]:
|
||||
config = load_meds_config()
|
||||
tz = config.timezone
|
||||
slot_ids = [s.id for s in config.slots]
|
||||
streak = await compute_streak(db, tz)
|
||||
|
||||
days = 90
|
||||
taken = 0
|
||||
total = 0
|
||||
today = datetime.now(tz).date()
|
||||
for i in range(days):
|
||||
d = (today - timedelta(days=i)).isoformat()
|
||||
for sid in slot_ids:
|
||||
total += 1
|
||||
cur = await db.execute(
|
||||
"SELECT 1 FROM intake_log WHERE day = ? AND slot_id = ? AND status = 'taken' LIMIT 1",
|
||||
(d, sid),
|
||||
)
|
||||
if await cur.fetchone():
|
||||
taken += 1
|
||||
await cur.close()
|
||||
|
||||
compliance = round((taken / total) * 100, 1) if total else 0.0
|
||||
milestones = await get_milestones(db)
|
||||
|
||||
return {
|
||||
"streak": streak,
|
||||
"compliance_percent": compliance,
|
||||
"total_taken": await count_logs(db),
|
||||
"milestones": milestones,
|
||||
}
|
||||
|
||||
|
||||
async def build_history(db: aiosqlite.Connection, days: int = 90) -> dict[str, Any]:
|
||||
config = load_meds_config()
|
||||
tz = config.timezone
|
||||
slot_ids = [s.id for s in config.slots]
|
||||
today = datetime.now(tz).date()
|
||||
days_out: list[dict[str, Any]] = []
|
||||
|
||||
for i in range(days - 1, -1, -1):
|
||||
d = (today - timedelta(days=i)).isoformat()
|
||||
cur = await db.execute(
|
||||
"SELECT slot_id, status FROM intake_log WHERE day = ?",
|
||||
(d,),
|
||||
)
|
||||
rows = await cur.fetchall()
|
||||
await cur.close()
|
||||
by_slot = {sid: st for sid, st in rows}
|
||||
slots_status = {}
|
||||
all_taken = True
|
||||
any_missed = False
|
||||
for sid in slot_ids:
|
||||
st = by_slot.get(sid)
|
||||
slots_status[sid] = st or "none"
|
||||
if st != "taken":
|
||||
all_taken = False
|
||||
if st == "missed":
|
||||
any_missed = True
|
||||
if all_taken and slot_ids:
|
||||
level = "good"
|
||||
elif any_missed:
|
||||
level = "bad"
|
||||
elif any(st != "none" for st in slots_status.values()):
|
||||
level = "partial"
|
||||
else:
|
||||
level = "none"
|
||||
days_out.append({"day": d, "level": level, "slots": slots_status})
|
||||
|
||||
stats = await compute_stats(db)
|
||||
return {"days": days_out, "stats": stats}
|
||||
Reference in New Issue
Block a user