371 lines
11 KiB
Python
371 lines
11 KiB
Python
from datetime import date as date_type
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional
|
|
|
|
from django.db.models import Avg, Count, Max, Min
|
|
from django.http import HttpRequest
|
|
from django.shortcuts import get_object_or_404
|
|
from django.utils import timezone
|
|
from ninja import Router, Schema
|
|
from ninja.errors import HttpError
|
|
|
|
from accounts.auth import AUTH
|
|
from tracker.models import (
|
|
DailyNote,
|
|
DoseLog,
|
|
GlobalNote,
|
|
Medication,
|
|
PulseReading,
|
|
ScheduledDose,
|
|
)
|
|
|
|
router = Router(auth=AUTH)
|
|
|
|
|
|
# --- Schemas ---------------------------------------------------------------
|
|
|
|
|
|
class ScheduledDoseOut(Schema):
|
|
id: int
|
|
medication_id: int
|
|
medication_name: str
|
|
amount: str
|
|
time_of_day: int
|
|
time_of_day_label: str
|
|
|
|
|
|
class MedicationOut(Schema):
|
|
id: int
|
|
name: str
|
|
is_active: bool
|
|
notes: str
|
|
scheduled_doses: list[ScheduledDoseOut]
|
|
|
|
|
|
class DoseStatusOut(ScheduledDoseOut):
|
|
"""A scheduled dose plus whether it was given on the day being looked at."""
|
|
|
|
taken: Optional[bool] # None = not recorded yet
|
|
notes: str
|
|
|
|
|
|
class PulseOut(Schema):
|
|
date: date_type
|
|
bpm: int
|
|
notes: str
|
|
|
|
|
|
class DailyNoteOut(Schema):
|
|
date: date_type
|
|
body: str
|
|
|
|
|
|
class DailyNoteIn(Schema):
|
|
body: str
|
|
date: Optional[date_type] = None
|
|
|
|
|
|
class GlobalNoteOut(Schema):
|
|
body: str
|
|
updated_at: Optional[datetime] = None
|
|
|
|
|
|
class GlobalNoteIn(Schema):
|
|
body: str
|
|
|
|
|
|
class DayOut(Schema):
|
|
date: date_type
|
|
doses: list[DoseStatusOut]
|
|
pulse: Optional[PulseOut]
|
|
note: Optional[DailyNoteOut]
|
|
|
|
|
|
class RecapDayOut(Schema):
|
|
"""One line of the recap: did every dose go out that day, and what was the pulse."""
|
|
|
|
date: date_type
|
|
taken: int
|
|
expected: int
|
|
complete: bool
|
|
bpm: Optional[int]
|
|
note: str
|
|
|
|
|
|
class DoseLogIn(Schema):
|
|
scheduled_dose_id: int
|
|
date: Optional[date_type] = None
|
|
taken: bool = True
|
|
notes: str = ""
|
|
|
|
|
|
class PulseIn(Schema):
|
|
bpm: int
|
|
date: Optional[date_type] = None
|
|
notes: str = ""
|
|
|
|
|
|
class PulseStatsOut(Schema):
|
|
count: int
|
|
average: Optional[float]
|
|
minimum: Optional[int]
|
|
maximum: Optional[int]
|
|
trend_bpm_per_day: Optional[float]
|
|
first_date: Optional[date_type]
|
|
last_date: Optional[date_type]
|
|
|
|
|
|
# --- Helpers ---------------------------------------------------------------
|
|
|
|
|
|
def _serialize_scheduled_dose(dose: ScheduledDose) -> dict:
|
|
return {
|
|
"id": dose.id,
|
|
"medication_id": dose.medication_id,
|
|
"medication_name": dose.medication.name,
|
|
"amount": dose.amount,
|
|
"time_of_day": dose.time_of_day,
|
|
"time_of_day_label": dose.get_time_of_day_display(),
|
|
}
|
|
|
|
|
|
def build_day(day: date_type) -> dict:
|
|
"""The checklist for one day: every active dose, with its recorded state."""
|
|
# Chronological, so the day reads morning → night and groups cleanly by time.
|
|
doses = (
|
|
ScheduledDose.objects.filter(is_active=True, medication__is_active=True)
|
|
.select_related("medication")
|
|
.order_by("time_of_day", "medication__name")
|
|
)
|
|
|
|
logs = {
|
|
log.scheduled_dose_id: log
|
|
for log in DoseLog.objects.filter(date=day, scheduled_dose__in=doses)
|
|
}
|
|
|
|
return {
|
|
"date": day,
|
|
"doses": [
|
|
{
|
|
**_serialize_scheduled_dose(dose),
|
|
"taken": logs[dose.id].taken if dose.id in logs else None,
|
|
"notes": logs[dose.id].notes if dose.id in logs else "",
|
|
}
|
|
for dose in doses
|
|
],
|
|
"pulse": PulseReading.objects.filter(date=day).first(),
|
|
"note": DailyNote.objects.filter(date=day).first(),
|
|
}
|
|
|
|
|
|
def build_recap(days: int) -> list[dict]:
|
|
"""One line per day, newest first: dose completeness, pulse and note.
|
|
|
|
"Complete" is measured against the doses that are active now, not the plan
|
|
as it stood on that day — the point is spotting gaps, not auditing history.
|
|
"""
|
|
today = timezone.localdate()
|
|
since = today - timedelta(days=days - 1)
|
|
|
|
expected = ScheduledDose.objects.filter(
|
|
is_active=True, medication__is_active=True
|
|
).count()
|
|
|
|
taken_by_day = {
|
|
row["date"]: row["taken"]
|
|
for row in DoseLog.objects.filter(
|
|
date__gte=since,
|
|
taken=True,
|
|
scheduled_dose__is_active=True,
|
|
scheduled_dose__medication__is_active=True,
|
|
)
|
|
.values("date")
|
|
.annotate(taken=Count("id"))
|
|
}
|
|
pulse_by_day = {
|
|
reading.date: reading.bpm
|
|
for reading in PulseReading.objects.filter(date__gte=since)
|
|
}
|
|
note_by_day = {
|
|
note.date: note.body for note in DailyNote.objects.filter(date__gte=since)
|
|
}
|
|
|
|
return [
|
|
{
|
|
"date": day,
|
|
"taken": taken_by_day.get(day, 0),
|
|
"expected": expected,
|
|
"complete": expected > 0 and taken_by_day.get(day, 0) == expected,
|
|
"bpm": pulse_by_day.get(day),
|
|
"note": note_by_day.get(day, ""),
|
|
}
|
|
for day in (today - timedelta(days=offset) for offset in range(days))
|
|
]
|
|
|
|
|
|
def pulse_trend(readings: list[PulseReading]) -> Optional[float]:
|
|
"""Least-squares slope in bpm/day: ~0 means stable, positive means climbing."""
|
|
if len(readings) < 2:
|
|
return None
|
|
|
|
origin = readings[0].date
|
|
xs = [(r.date - origin).days for r in readings]
|
|
ys = [r.bpm for r in readings]
|
|
n = len(xs)
|
|
mean_x = sum(xs) / n
|
|
mean_y = sum(ys) / n
|
|
|
|
variance = sum((x - mean_x) ** 2 for x in xs)
|
|
if variance == 0:
|
|
return None
|
|
|
|
covariance = sum((x - mean_x) * (y - mean_y) for x, y in zip(xs, ys))
|
|
return round(covariance / variance, 3)
|
|
|
|
|
|
# --- Medications -----------------------------------------------------------
|
|
|
|
|
|
@router.get("/medications", response=list[MedicationOut])
|
|
def list_medications(request: HttpRequest, include_inactive: bool = False):
|
|
"""The medication plan. Edit it in the Django admin."""
|
|
medications = Medication.objects.prefetch_related("scheduled_doses__medication")
|
|
if not include_inactive:
|
|
medications = medications.filter(is_active=True)
|
|
|
|
return [
|
|
{
|
|
"id": med.id,
|
|
"name": med.name,
|
|
"is_active": med.is_active,
|
|
"notes": med.notes,
|
|
"scheduled_doses": [
|
|
_serialize_scheduled_dose(dose)
|
|
for dose in med.scheduled_doses.all()
|
|
if dose.is_active or include_inactive
|
|
],
|
|
}
|
|
for med in medications
|
|
]
|
|
|
|
|
|
# --- Daily doses -----------------------------------------------------------
|
|
|
|
|
|
@router.get("/day", response=DayOut)
|
|
def get_day(request: HttpRequest, date: Optional[date_type] = None):
|
|
"""Everything for one day (defaults to today): the dose checklist and the pulse."""
|
|
return build_day(date or timezone.localdate())
|
|
|
|
|
|
@router.get("/recap", response=list[RecapDayOut])
|
|
def get_recap(request: HttpRequest, days: int = 14):
|
|
"""Day-by-day recap, newest first: were all doses given, pulse and note."""
|
|
return build_recap(days)
|
|
|
|
|
|
@router.post("/doses", response=DayOut)
|
|
def log_dose(request: HttpRequest, payload: DoseLogIn):
|
|
"""Tick a dose off (or untick it). Idempotent per (dose, day)."""
|
|
day = payload.date or timezone.localdate()
|
|
dose = get_object_or_404(ScheduledDose, id=payload.scheduled_dose_id)
|
|
|
|
log, _ = DoseLog.objects.get_or_create(
|
|
scheduled_dose=dose, date=day, defaults={"taken": payload.taken}
|
|
)
|
|
log.taken = payload.taken
|
|
log.notes = payload.notes
|
|
log.save()
|
|
|
|
return build_day(day)
|
|
|
|
|
|
# --- Notes -----------------------------------------------------------------
|
|
|
|
|
|
@router.get("/notes/daily", response=list[DailyNoteOut])
|
|
def list_daily_notes(request: HttpRequest):
|
|
"""The whole journal, newest first."""
|
|
return DailyNote.objects.all()
|
|
|
|
|
|
@router.post("/notes/daily", response=DayOut)
|
|
def save_daily_note(request: HttpRequest, payload: DailyNoteIn):
|
|
"""The day's journal entry. One per day; an empty body removes it."""
|
|
day = payload.date or timezone.localdate()
|
|
|
|
if payload.body.strip():
|
|
DailyNote.objects.update_or_create(date=day, defaults={"body": payload.body})
|
|
else:
|
|
DailyNote.objects.filter(date=day).delete()
|
|
|
|
return build_day(day)
|
|
|
|
|
|
@router.get("/notes/global", response=GlobalNoteOut)
|
|
def get_global_note(request: HttpRequest):
|
|
"""The single shared note: standing instructions and things to do."""
|
|
return GlobalNote.load()
|
|
|
|
|
|
@router.put("/notes/global", response=GlobalNoteOut)
|
|
def save_global_note(request: HttpRequest, payload: GlobalNoteIn):
|
|
"""Replace the shared note's content. Last write wins."""
|
|
note = GlobalNote.load()
|
|
note.body = payload.body
|
|
note.save(update_fields=["body", "updated_at"])
|
|
return note
|
|
|
|
|
|
# --- Pulse -----------------------------------------------------------------
|
|
|
|
|
|
@router.get("/pulse", response=list[PulseOut])
|
|
def list_pulse(request: HttpRequest, days: int = 90):
|
|
"""Readings for the graph, oldest first."""
|
|
since = timezone.localdate() - timedelta(days=days)
|
|
return PulseReading.objects.filter(date__gte=since).order_by("date")
|
|
|
|
|
|
@router.post("/pulse", response=PulseOut)
|
|
def record_pulse(request: HttpRequest, payload: PulseIn):
|
|
"""One reading per day: posting twice for the same day overwrites it."""
|
|
if not 20 <= payload.bpm <= 400:
|
|
raise HttpError(422, "A pulse of that value is not plausible for a cat.")
|
|
|
|
day = payload.date or timezone.localdate()
|
|
reading, _ = PulseReading.objects.update_or_create(
|
|
date=day, defaults={"bpm": payload.bpm, "notes": payload.notes}
|
|
)
|
|
return reading
|
|
|
|
|
|
@router.get("/pulse/stats", response=PulseStatsOut)
|
|
def pulse_stats(request: HttpRequest, days: int = 90):
|
|
"""Is the pulse steady or drifting? `trend_bpm_per_day` is the slope.
|
|
|
|
Declared before /pulse/{date}, which would otherwise match "stats" first.
|
|
"""
|
|
since = timezone.localdate() - timedelta(days=days)
|
|
readings = list(PulseReading.objects.filter(date__gte=since).order_by("date"))
|
|
|
|
aggregates = PulseReading.objects.filter(date__gte=since).aggregate(
|
|
average=Avg("bpm"), minimum=Min("bpm"), maximum=Max("bpm")
|
|
)
|
|
|
|
return {
|
|
"count": len(readings),
|
|
"average": round(aggregates["average"], 1) if aggregates["average"] else None,
|
|
"minimum": aggregates["minimum"],
|
|
"maximum": aggregates["maximum"],
|
|
"trend_bpm_per_day": pulse_trend(readings),
|
|
"first_date": readings[0].date if readings else None,
|
|
"last_date": readings[-1].date if readings else None,
|
|
}
|
|
|
|
|
|
@router.delete("/pulse/{date}")
|
|
def delete_pulse(request: HttpRequest, date: date_type):
|
|
get_object_or_404(PulseReading, date=date).delete()
|
|
return {"detail": "Deleted."}
|