first version
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from tracker.models import DoseLog, Medication, PulseReading, ScheduledDose
|
||||
|
||||
|
||||
class ScheduledDoseInline(admin.TabularInline):
|
||||
"""Edit a medication's daily doses right on the medication page."""
|
||||
|
||||
model = ScheduledDose
|
||||
extra = 1
|
||||
|
||||
|
||||
@admin.register(Medication)
|
||||
class MedicationAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "is_active", "schedule_summary"]
|
||||
list_filter = ["is_active"]
|
||||
search_fields = ["name"]
|
||||
inlines = [ScheduledDoseInline]
|
||||
|
||||
@admin.display(description="Schedule")
|
||||
def schedule_summary(self, obj: Medication) -> str:
|
||||
doses = obj.scheduled_doses.filter(is_active=True)
|
||||
if not doses:
|
||||
return "—"
|
||||
return ", ".join(
|
||||
f"{d.amount} {d.get_time_of_day_display().lower()}" for d in doses
|
||||
)
|
||||
|
||||
|
||||
@admin.register(ScheduledDose)
|
||||
class ScheduledDoseAdmin(admin.ModelAdmin):
|
||||
list_display = ["medication", "time_of_day", "amount", "is_active"]
|
||||
list_filter = ["is_active", "time_of_day", "medication"]
|
||||
|
||||
|
||||
@admin.register(DoseLog)
|
||||
class DoseLogAdmin(admin.ModelAdmin):
|
||||
"""The history. Day-to-day ticking off happens on the Today page."""
|
||||
|
||||
list_display = ["date", "scheduled_dose", "taken", "taken_at"]
|
||||
list_filter = ["taken", "date", "scheduled_dose__medication"]
|
||||
date_hierarchy = "date"
|
||||
|
||||
|
||||
@admin.register(PulseReading)
|
||||
class PulseReadingAdmin(admin.ModelAdmin):
|
||||
list_display = ["date", "bpm", "notes"]
|
||||
date_hierarchy = "date"
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
from datetime import date as date_type
|
||||
from datetime import timedelta
|
||||
from typing import Optional
|
||||
|
||||
from django.db.models import Avg, 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 DoseLog, 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 DayOut(Schema):
|
||||
date: date_type
|
||||
doses: list[DoseStatusOut]
|
||||
pulse: Optional[PulseOut]
|
||||
|
||||
|
||||
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(),
|
||||
}
|
||||
|
||||
|
||||
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.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)
|
||||
|
||||
|
||||
# --- 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."}
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TrackerConfig(AppConfig):
|
||||
name = "tracker"
|
||||
@@ -0,0 +1,157 @@
|
||||
# Generated by Django 6.0.7 on 2026-07-14 18:04
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Medication",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=100, unique=True)),
|
||||
(
|
||||
"is_active",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text="Uncheck when the treatment ends, instead of deleting.",
|
||||
),
|
||||
),
|
||||
("notes", models.TextField(blank=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ["name"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="PulseReading",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"date",
|
||||
models.DateField(
|
||||
default=django.utils.timezone.localdate, unique=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"bpm",
|
||||
models.PositiveSmallIntegerField(help_text="Beats per minute."),
|
||||
),
|
||||
("notes", models.TextField(blank=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-date"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ScheduledDose",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"time_of_day",
|
||||
models.IntegerField(
|
||||
choices=[
|
||||
(1, "Morning"),
|
||||
(2, "Noon"),
|
||||
(3, "Evening"),
|
||||
(4, "Night"),
|
||||
]
|
||||
),
|
||||
),
|
||||
(
|
||||
"amount",
|
||||
models.CharField(
|
||||
default="1/4",
|
||||
help_text='Free text, e.g. "1/4", "1", "5 mg".',
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("is_active", models.BooleanField(default=True)),
|
||||
(
|
||||
"medication",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="scheduled_doses",
|
||||
to="tracker.medication",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["medication__name", "time_of_day"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="DoseLog",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("date", models.DateField(default=django.utils.timezone.localdate)),
|
||||
("taken", models.BooleanField(default=True)),
|
||||
("taken_at", models.DateTimeField(blank=True, null=True)),
|
||||
("notes", models.TextField(blank=True)),
|
||||
(
|
||||
"scheduled_dose",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="logs",
|
||||
to="tracker.scheduleddose",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-date", "scheduled_dose__time_of_day"],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="scheduleddose",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("medication", "time_of_day"),
|
||||
name="unique_medication_time_of_day",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="doselog",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("scheduled_dose", "date"), name="unique_dose_per_day"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,107 @@
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class TimeOfDay(models.IntegerChoices):
|
||||
"""Integer-backed so that ordering by this field is chronological."""
|
||||
|
||||
MORNING = 1, "Morning"
|
||||
NOON = 2, "Noon"
|
||||
EVENING = 3, "Evening"
|
||||
NIGHT = 4, "Night"
|
||||
|
||||
|
||||
class Medication(models.Model):
|
||||
"""A drug Osiris is on, e.g. "Vetmedin"."""
|
||||
|
||||
name = models.CharField(max_length=100, unique=True)
|
||||
is_active = models.BooleanField(
|
||||
default=True, help_text="Uncheck when the treatment ends, instead of deleting."
|
||||
)
|
||||
notes = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class ScheduledDose(models.Model):
|
||||
"""One recurring dose of a medication, e.g. "Vetmedin, 1/4, every morning".
|
||||
|
||||
A medication taken morning and evening has two of these.
|
||||
"""
|
||||
|
||||
medication = models.ForeignKey(
|
||||
Medication, on_delete=models.CASCADE, related_name="scheduled_doses"
|
||||
)
|
||||
time_of_day = models.IntegerField(choices=TimeOfDay.choices)
|
||||
amount = models.CharField(
|
||||
max_length=20, default="1/4", help_text='Free text, e.g. "1/4", "1", "5 mg".'
|
||||
)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["medication__name", "time_of_day"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["medication", "time_of_day"],
|
||||
name="unique_medication_time_of_day",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.medication.name} — {self.amount} ({self.get_time_of_day_display().lower()})"
|
||||
|
||||
|
||||
class DoseLog(models.Model):
|
||||
"""Whether a given scheduled dose was actually given on a given day.
|
||||
|
||||
A missing row means "not recorded"; a row with taken=False means "explicitly
|
||||
marked as not given", so unchecking a box is distinguishable from never
|
||||
having touched it.
|
||||
"""
|
||||
|
||||
scheduled_dose = models.ForeignKey(
|
||||
ScheduledDose, on_delete=models.CASCADE, related_name="logs"
|
||||
)
|
||||
date = models.DateField(default=timezone.localdate)
|
||||
taken = models.BooleanField(default=True)
|
||||
taken_at = models.DateTimeField(null=True, blank=True)
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-date", "scheduled_dose__time_of_day"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["scheduled_dose", "date"], name="unique_dose_per_day"
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
state = "given" if self.taken else "missed"
|
||||
return f"{self.date} — {self.scheduled_dose} — {state}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.taken and self.taken_at is None:
|
||||
self.taken_at = timezone.now()
|
||||
if not self.taken:
|
||||
self.taken_at = None
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class PulseReading(models.Model):
|
||||
"""One heart-rate measurement per day, in beats per minute."""
|
||||
|
||||
date = models.DateField(default=timezone.localdate, unique=True)
|
||||
bpm = models.PositiveSmallIntegerField(help_text="Beats per minute.")
|
||||
notes = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-date"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.date} — {self.bpm} bpm"
|
||||
@@ -0,0 +1,231 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from accounts.models import User
|
||||
from tracker.models import DoseLog, Medication, PulseReading, ScheduledDose, TimeOfDay
|
||||
|
||||
|
||||
class TrackerTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_superuser("loic", password="hunter2hunter2")
|
||||
|
||||
# Medication 1: 1/4 every morning.
|
||||
self.vetmedin = Medication.objects.create(name="Vetmedin")
|
||||
self.morning_only = ScheduledDose.objects.create(
|
||||
medication=self.vetmedin, time_of_day=TimeOfDay.MORNING, amount="1/4"
|
||||
)
|
||||
|
||||
# Medication 2: 1/4 morning and 1/4 evening.
|
||||
self.fortekor = Medication.objects.create(name="Fortekor")
|
||||
self.morning = ScheduledDose.objects.create(
|
||||
medication=self.fortekor, time_of_day=TimeOfDay.MORNING, amount="1/4"
|
||||
)
|
||||
self.evening = ScheduledDose.objects.create(
|
||||
medication=self.fortekor, time_of_day=TimeOfDay.EVENING, amount="1/4"
|
||||
)
|
||||
|
||||
def token(self) -> str:
|
||||
response = self.client.post(
|
||||
"/api/auth/login",
|
||||
{"username": "loic", "password": "hunter2hunter2"},
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return response.json()["token"]
|
||||
|
||||
def auth_header(self) -> dict:
|
||||
return {"HTTP_AUTHORIZATION": f"Bearer {self.token()}"}
|
||||
|
||||
|
||||
class AuthTests(TrackerTestCase):
|
||||
def test_login_rejects_a_bad_password(self):
|
||||
response = self.client.post(
|
||||
"/api/auth/login",
|
||||
{"username": "loic", "password": "wrong"},
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_endpoints_require_authentication(self):
|
||||
self.assertEqual(self.client.get("/api/tracker/day").status_code, 401)
|
||||
|
||||
def test_health_is_public(self):
|
||||
self.assertEqual(self.client.get("/api/health").status_code, 200)
|
||||
|
||||
|
||||
class DoseApiTests(TrackerTestCase):
|
||||
def test_day_lists_every_scheduled_dose_unrecorded(self):
|
||||
response = self.client.get("/api/tracker/day", **self.auth_header())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
doses = response.json()["doses"]
|
||||
self.assertEqual(len(doses), 3)
|
||||
self.assertTrue(all(dose["taken"] is None for dose in doses))
|
||||
# Morning doses come before the evening one.
|
||||
self.assertEqual([d["time_of_day"] for d in doses], [1, 1, 3])
|
||||
|
||||
def test_logging_a_dose_marks_only_that_dose(self):
|
||||
response = self.client.post(
|
||||
"/api/tracker/doses",
|
||||
{"scheduled_dose_id": self.morning.id, "taken": True},
|
||||
content_type="application/json",
|
||||
**self.auth_header(),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
doses = {d["id"]: d["taken"] for d in response.json()["doses"]}
|
||||
self.assertIs(doses[self.morning.id], True)
|
||||
self.assertIsNone(doses[self.evening.id])
|
||||
self.assertIsNone(doses[self.morning_only.id])
|
||||
|
||||
def test_logging_the_same_dose_twice_updates_in_place(self):
|
||||
for taken in (True, False):
|
||||
self.client.post(
|
||||
"/api/tracker/doses",
|
||||
{"scheduled_dose_id": self.morning.id, "taken": taken},
|
||||
content_type="application/json",
|
||||
**self.auth_header(),
|
||||
)
|
||||
|
||||
log = DoseLog.objects.get(scheduled_dose=self.morning)
|
||||
self.assertFalse(log.taken)
|
||||
self.assertIsNone(log.taken_at)
|
||||
self.assertEqual(DoseLog.objects.count(), 1)
|
||||
|
||||
def test_a_dose_can_be_logged_for_a_past_day(self):
|
||||
yesterday = timezone.localdate() - timedelta(days=1)
|
||||
self.client.post(
|
||||
"/api/tracker/doses",
|
||||
{
|
||||
"scheduled_dose_id": self.evening.id,
|
||||
"date": yesterday.isoformat(),
|
||||
"taken": True,
|
||||
},
|
||||
content_type="application/json",
|
||||
**self.auth_header(),
|
||||
)
|
||||
|
||||
self.assertTrue(DoseLog.objects.filter(date=yesterday, taken=True).exists())
|
||||
self.assertFalse(DoseLog.objects.filter(date=timezone.localdate()).exists())
|
||||
|
||||
def test_inactive_medication_drops_out_of_the_day(self):
|
||||
self.fortekor.is_active = False
|
||||
self.fortekor.save()
|
||||
|
||||
response = self.client.get("/api/tracker/day", **self.auth_header())
|
||||
self.assertEqual(len(response.json()["doses"]), 1)
|
||||
|
||||
|
||||
class PulseApiTests(TrackerTestCase):
|
||||
def test_recording_the_pulse_twice_in_a_day_overwrites(self):
|
||||
for bpm in (180, 192):
|
||||
response = self.client.post(
|
||||
"/api/tracker/pulse",
|
||||
{"bpm": bpm},
|
||||
content_type="application/json",
|
||||
**self.auth_header(),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.assertEqual(PulseReading.objects.count(), 1)
|
||||
self.assertEqual(PulseReading.objects.get().bpm, 192)
|
||||
|
||||
def test_an_implausible_pulse_is_rejected(self):
|
||||
response = self.client.post(
|
||||
"/api/tracker/pulse",
|
||||
{"bpm": 5},
|
||||
content_type="application/json",
|
||||
**self.auth_header(),
|
||||
)
|
||||
self.assertEqual(response.status_code, 422)
|
||||
|
||||
def test_stats_report_a_rising_trend(self):
|
||||
today = timezone.localdate()
|
||||
for offset, bpm in enumerate([160, 170, 180, 190]):
|
||||
PulseReading.objects.create(
|
||||
date=today - timedelta(days=3 - offset), bpm=bpm
|
||||
)
|
||||
|
||||
stats = self.client.get("/api/tracker/pulse/stats", **self.auth_header()).json()
|
||||
|
||||
self.assertEqual(stats["count"], 4)
|
||||
self.assertEqual(stats["minimum"], 160)
|
||||
self.assertEqual(stats["maximum"], 190)
|
||||
self.assertEqual(stats["average"], 175.0)
|
||||
self.assertEqual(stats["trend_bpm_per_day"], 10.0)
|
||||
|
||||
def test_stats_are_empty_without_readings(self):
|
||||
stats = self.client.get("/api/tracker/pulse/stats", **self.auth_header()).json()
|
||||
|
||||
self.assertEqual(stats["count"], 0)
|
||||
self.assertIsNone(stats["average"])
|
||||
self.assertIsNone(stats["trend_bpm_per_day"])
|
||||
|
||||
|
||||
class TodayPageTests(TrackerTestCase):
|
||||
def test_the_page_requires_login(self):
|
||||
response = self.client.get(reverse("tracker:today"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn("/admin/login/", response["Location"])
|
||||
|
||||
def test_the_page_lists_the_doses(self):
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("tracker:today"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Vetmedin")
|
||||
self.assertContains(response, "Fortekor")
|
||||
self.assertContains(response, "Morning")
|
||||
self.assertContains(response, "Evening")
|
||||
# Nothing ticked yet, so every dose reads as unrecorded rather than missed.
|
||||
self.assertContains(response, "not recorded", count=3)
|
||||
self.assertNotContains(response, "checked")
|
||||
|
||||
def test_saving_ticks_doses_and_records_the_pulse(self):
|
||||
self.client.force_login(self.user)
|
||||
today = timezone.localdate()
|
||||
|
||||
response = self.client.post(
|
||||
reverse("tracker:today"),
|
||||
{
|
||||
"date": today.isoformat(),
|
||||
f"dose-{self.morning.id}": "on",
|
||||
f"dose-{self.morning_only.id}": "on",
|
||||
# The evening checkbox is absent: not given.
|
||||
"bpm": "188",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
self.assertTrue(DoseLog.objects.get(scheduled_dose=self.morning).taken)
|
||||
self.assertTrue(DoseLog.objects.get(scheduled_dose=self.morning_only).taken)
|
||||
self.assertFalse(DoseLog.objects.get(scheduled_dose=self.evening).taken)
|
||||
self.assertEqual(PulseReading.objects.get(date=today).bpm, 188)
|
||||
|
||||
def test_clearing_the_pulse_field_removes_the_reading(self):
|
||||
self.client.force_login(self.user)
|
||||
today = timezone.localdate()
|
||||
PulseReading.objects.create(date=today, bpm=200)
|
||||
|
||||
self.client.post(
|
||||
reverse("tracker:today"), {"date": today.isoformat(), "bpm": ""}
|
||||
)
|
||||
|
||||
self.assertFalse(PulseReading.objects.filter(date=today).exists())
|
||||
|
||||
def test_the_graph_appears_once_there_are_two_readings(self):
|
||||
self.client.force_login(self.user)
|
||||
today = timezone.localdate()
|
||||
|
||||
response = self.client.get(reverse("tracker:today"))
|
||||
self.assertContains(response, "at least two days")
|
||||
|
||||
PulseReading.objects.create(date=today - timedelta(days=1), bpm=170)
|
||||
PulseReading.objects.create(date=today, bpm=190)
|
||||
|
||||
response = self.client.get(reverse("tracker:today"))
|
||||
self.assertContains(response, "<polyline")
|
||||
self.assertContains(response, "180.0 bpm") # average
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from tracker import views
|
||||
|
||||
app_name = "tracker"
|
||||
|
||||
urlpatterns = [
|
||||
path("today/", views.today, name="today"),
|
||||
]
|
||||
@@ -0,0 +1,107 @@
|
||||
from datetime import timedelta
|
||||
from typing import Optional
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.admin.views.decorators import staff_member_required
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_date
|
||||
|
||||
from tracker.api import build_day, pulse_trend
|
||||
from tracker.models import DoseLog, PulseReading, ScheduledDose
|
||||
|
||||
CHART_WIDTH = 320
|
||||
CHART_HEIGHT = 140
|
||||
CHART_PADDING = 24
|
||||
|
||||
|
||||
def build_chart(readings: list[PulseReading]) -> Optional[dict]:
|
||||
"""Lay the readings out as SVG coordinates, so the page needs no JavaScript."""
|
||||
if len(readings) < 2:
|
||||
return None
|
||||
|
||||
bpms = [r.bpm for r in readings]
|
||||
low, high = min(bpms), max(bpms)
|
||||
# A flat line would divide by zero, and a narrow range exaggerates noise.
|
||||
span = max(high - low, 10)
|
||||
mid = (high + low) / 2
|
||||
low, high = mid - span / 2, mid + span / 2
|
||||
|
||||
first_day = readings[0].date
|
||||
total_days = max((readings[-1].date - first_day).days, 1)
|
||||
|
||||
inner_width = CHART_WIDTH - 2 * CHART_PADDING
|
||||
inner_height = CHART_HEIGHT - 2 * CHART_PADDING
|
||||
|
||||
points = []
|
||||
for reading in readings:
|
||||
x = CHART_PADDING + (reading.date - first_day).days / total_days * inner_width
|
||||
y = CHART_PADDING + (high - reading.bpm) / (high - low) * inner_height
|
||||
points.append({"x": round(x, 1), "y": round(y, 1), "reading": reading})
|
||||
|
||||
return {
|
||||
"points": points,
|
||||
"polyline": " ".join(f"{p['x']},{p['y']}" for p in points),
|
||||
"low": round(low),
|
||||
"high": round(high),
|
||||
"average": round(sum(bpms) / len(bpms), 1),
|
||||
"trend": pulse_trend(readings),
|
||||
"width": CHART_WIDTH,
|
||||
"height": CHART_HEIGHT,
|
||||
"first_date": first_day,
|
||||
"last_date": readings[-1].date,
|
||||
}
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def today(request):
|
||||
"""The main screen: tick off the day's doses and record the pulse."""
|
||||
day = parse_date(request.GET.get("date", "")) or timezone.localdate()
|
||||
|
||||
if request.method == "POST":
|
||||
day = parse_date(request.POST.get("date", "")) or timezone.localdate()
|
||||
doses = ScheduledDose.objects.filter(is_active=True, medication__is_active=True)
|
||||
|
||||
for dose in doses:
|
||||
taken = f"dose-{dose.id}" in request.POST
|
||||
log, _ = DoseLog.objects.get_or_create(
|
||||
scheduled_dose=dose, date=day, defaults={"taken": taken}
|
||||
)
|
||||
log.taken = taken
|
||||
log.save()
|
||||
|
||||
bpm = (request.POST.get("bpm") or "").strip()
|
||||
if bpm:
|
||||
try:
|
||||
PulseReading.objects.update_or_create(
|
||||
date=day, defaults={"bpm": int(bpm)}
|
||||
)
|
||||
except ValueError:
|
||||
messages.error(request, f"'{bpm}' is not a valid pulse.")
|
||||
else:
|
||||
# Clearing the field removes the reading for that day.
|
||||
PulseReading.objects.filter(date=day).delete()
|
||||
|
||||
messages.success(request, f"Saved for {day:%A %d %B}.")
|
||||
return redirect(f"{reverse('tracker:today')}?date={day.isoformat()}")
|
||||
|
||||
readings = list(
|
||||
PulseReading.objects.filter(
|
||||
date__gte=timezone.localdate() - timedelta(days=90)
|
||||
).order_by("date")
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"tracker/today.html",
|
||||
{
|
||||
"title": "Today",
|
||||
"day": build_day(day),
|
||||
"is_today": day == timezone.localdate(),
|
||||
"previous_date": day - timedelta(days=1),
|
||||
"next_date": day + timedelta(days=1),
|
||||
"chart": build_chart(readings),
|
||||
"reading_count": len(readings),
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user