From 4040b4fdd949b0916b6fca3199d630765446fbf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Gremaud?= Date: Tue, 14 Jul 2026 20:10:17 +0200 Subject: [PATCH] first version --- .gitignore | 15 + .python-version | 1 + README.md | 99 +++++++ accounts/__init__.py | 0 accounts/admin.py | 18 ++ accounts/api.py | 50 ++++ accounts/apps.py | 5 + accounts/auth.py | 29 ++ accounts/migrations/0001_initial.py | 169 +++++++++++ accounts/migrations/__init__.py | 0 accounts/models.py | 42 +++ manage.py | 23 ++ osiris/__init__.py | 0 osiris/api.py | 24 ++ osiris/asgi.py | 16 ++ osiris/settings.py | 144 ++++++++++ osiris/urls.py | 19 ++ osiris/wsgi.py | 16 ++ pyproject.toml | 17 ++ templates/admin/base_site.html | 16 ++ templates/tracker/today.html | 149 ++++++++++ tracker/__init__.py | 0 tracker/admin.py | 48 ++++ tracker/api.py | 243 ++++++++++++++++ tracker/apps.py | 5 + tracker/migrations/0001_initial.py | 157 +++++++++++ tracker/migrations/__init__.py | 0 tracker/models.py | 107 +++++++ tracker/tests.py | 231 +++++++++++++++ tracker/urls.py | 9 + tracker/views.py | 107 +++++++ uv.lock | 417 ++++++++++++++++++++++++++++ 32 files changed, 2176 insertions(+) create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 README.md create mode 100644 accounts/__init__.py create mode 100644 accounts/admin.py create mode 100644 accounts/api.py create mode 100644 accounts/apps.py create mode 100644 accounts/auth.py create mode 100644 accounts/migrations/0001_initial.py create mode 100644 accounts/migrations/__init__.py create mode 100644 accounts/models.py create mode 100755 manage.py create mode 100644 osiris/__init__.py create mode 100644 osiris/api.py create mode 100644 osiris/asgi.py create mode 100644 osiris/settings.py create mode 100644 osiris/urls.py create mode 100644 osiris/wsgi.py create mode 100644 pyproject.toml create mode 100644 templates/admin/base_site.html create mode 100644 templates/tracker/today.html create mode 100644 tracker/__init__.py create mode 100644 tracker/admin.py create mode 100644 tracker/api.py create mode 100644 tracker/apps.py create mode 100644 tracker/migrations/0001_initial.py create mode 100644 tracker/migrations/__init__.py create mode 100644 tracker/models.py create mode 100644 tracker/tests.py create mode 100644 tracker/urls.py create mode 100644 tracker/views.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..349cf7b --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv + +# Django +db.sqlite3 +static/ +.env diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/README.md b/README.md new file mode 100644 index 0000000..0650ed9 --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# Osiris + +Tracking Osiris the cat: which medication he actually got, and his daily pulse. + +Django 5/6 + django-ninja, SQLite. The Django admin is the web UI for now; the +API is there so the Flutter app can be a second client without backend changes. + +## Setup + +```bash +uv sync +uv run python manage.py migrate +uv run python manage.py createsuperuser # the only way to make an account: there is no registration +uv run python manage.py runserver +``` + +Then open — it redirects to the Today page. + +To reach it from your phone on the same network: + +```bash +DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.1.x:8000 uv run python manage.py runserver 0.0.0.0:8000 +``` + +## Using it + +1. **Admin → Medications → Add**: create a medication and give it one row per + daily dose. "1/4 each morning" is one row; "1/4 morning and 1/4 evening" is + two rows on the same medication. +2. **Today page** (`/admin/today/`, linked from every admin page): tick off the + doses, type the pulse, hit Save. Use *Prev* to fill in a day you missed. +3. The pulse graph on that page covers the last 90 days and shows the trend in + bpm/day, so a slow drift is visible rather than just day-to-day noise. + +Sessions last a year, so your phone stays logged in. + +## Data model + +- `Medication` — the drug, e.g. Vetmedin. Deactivate rather than delete when a + treatment ends; the history stays intact. +- `ScheduledDose` — one recurring dose: medication + time of day + amount. +- `DoseLog` — one row per (scheduled dose, day). No row means "not recorded"; + `taken=False` means "explicitly not given", so an untouched day is + distinguishable from a missed dose. +- `PulseReading` — one per day, unique on the date. + +## API + +Browsable docs at `/api/docs`, OpenAPI schema at `/api/openapi.json` (feed that +to a Dart client generator). + +Session cookies authenticate the admin; the Flutter app uses a bearer token: + +```bash +curl -X POST localhost:8000/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username": "you", "password": "...", "device_name": "pixel"}' +# => {"token": "...", "username": "you"} +``` + +Send it as `Authorization: Bearer ` on every other call. Tokens do not +expire; revoke them in Admin → Api tokens. + +| Method | Path | Purpose | +| -------- | -------------------------- | ------------------------------------------------ | +| `POST` | `/api/auth/login` | username + password → token | +| `POST` | `/api/auth/logout` | revoke the token used for the call | +| `GET` | `/api/auth/me` | current user | +| `GET` | `/api/tracker/medications` | the plan: medications and their scheduled doses | +| `GET` | `/api/tracker/day` | `?date=` (default today): checklist + pulse | +| `POST` | `/api/tracker/doses` | tick a dose on/off; returns the updated day | +| `GET` | `/api/tracker/pulse` | `?days=90`, oldest first — the graph data | +| `POST` | `/api/tracker/pulse` | record the pulse; posting twice a day overwrites | +| `GET` | `/api/tracker/pulse/stats` | average, range, and `trend_bpm_per_day` | +| `DELETE` | `/api/tracker/pulse/{date}`| remove a reading | + +`GET /api/tracker/day` is the one the app's home screen needs: + +```json +{ + "date": "2026-07-14", + "doses": [ + {"id": 2, "medication_name": "Fortekor", "amount": "1/4", + "time_of_day": 1, "time_of_day_label": "Morning", "taken": true, "notes": ""}, + {"id": 3, "medication_name": "Fortekor", "amount": "1/4", + "time_of_day": 3, "time_of_day_label": "Evening", "taken": null, "notes": ""} + ], + "pulse": {"date": "2026-07-14", "bpm": 188, "notes": ""} +} +``` + +`taken: null` means not recorded yet, as opposed to `false` for a missed dose. + +## Tests + +```bash +uv run python manage.py test +uv run ruff check . && uv run ruff format . +``` diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..7700f34 --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,18 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin + +from accounts.models import ApiToken, User + + +@admin.register(User) +class OsirisUserAdmin(UserAdmin): + pass + + +@admin.register(ApiToken) +class ApiTokenAdmin(admin.ModelAdmin): + """Tokens are created by the Flutter app via /api/auth/login; this is for revoking.""" + + list_display = ["name", "user", "key", "created_at", "last_used_at"] + readonly_fields = ["key", "created_at", "last_used_at"] + list_filter = ["user"] diff --git a/accounts/api.py b/accounts/api.py new file mode 100644 index 0000000..d225699 --- /dev/null +++ b/accounts/api.py @@ -0,0 +1,50 @@ +from django.contrib.auth import authenticate +from django.http import HttpRequest +from ninja import Router, Schema +from ninja.errors import HttpError + +from accounts.auth import AUTH +from accounts.models import ApiToken + +router = Router() + + +class LoginIn(Schema): + username: str + password: str + device_name: str = "" + + +class TokenOut(Schema): + token: str + username: str + + +class UserOut(Schema): + username: str + is_staff: bool + + +@router.post("/login", response=TokenOut, auth=None) +def login(request: HttpRequest, payload: LoginIn): + """Exchange username/password for a bearer token. There is no registration.""" + user = authenticate(request, username=payload.username, password=payload.password) + if user is None: + raise HttpError(401, "Invalid username or password.") + + token = ApiToken.objects.create(user=user, name=payload.device_name) + return {"token": token.key, "username": user.username} + + +@router.post("/logout", auth=AUTH) +def logout(request: HttpRequest): + """Revoke the token used for this request. Session logins are unaffected.""" + token = request.auth + if isinstance(token, ApiToken): + token.delete() + return {"detail": "Logged out."} + + +@router.get("/me", response=UserOut, auth=AUTH) +def me(request: HttpRequest): + return request.user diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..fb0257e --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + name = "accounts" diff --git a/accounts/auth.py b/accounts/auth.py new file mode 100644 index 0000000..63e3f05 --- /dev/null +++ b/accounts/auth.py @@ -0,0 +1,29 @@ +from typing import Optional + +from django.http import HttpRequest +from ninja.security import HttpBearer, django_auth + +from accounts.models import ApiToken + + +class TokenAuth(HttpBearer): + """`Authorization: Bearer ` — how the Flutter app authenticates.""" + + def authenticate(self, request: HttpRequest, token: str) -> Optional[ApiToken]: + try: + api_token = ApiToken.objects.select_related("user").get(key=token) + except ApiToken.DoesNotExist: + return None + + if not api_token.user.is_active: + return None + + api_token.touch() + # Downstream views read request.user, same as with session auth. + request.user = api_token.user + return api_token + + +# Bearer first: a request carrying a token never reaches the cookie auth, which +# would reject it for a missing CSRF token. +AUTH = [TokenAuth(), django_auth] diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..3ccab6c --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,169 @@ +# Generated by Django 6.0.7 on 2026-07-14 18:04 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name="username", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "email", + models.EmailField( + blank=True, max_length=254, verbose_name="email address" + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "verbose_name": "user", + "verbose_name_plural": "users", + "abstract": False, + }, + managers=[ + ("objects", django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name="ApiToken", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("key", models.CharField(editable=False, max_length=64, unique=True)), + ( + "name", + models.CharField( + blank=True, + help_text="Which device this token is for.", + max_length=100, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("last_used_at", models.DateTimeField(blank=True, null=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="api_tokens", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-created_at"], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/models.py b/accounts/models.py new file mode 100644 index 0000000..f4bff16 --- /dev/null +++ b/accounts/models.py @@ -0,0 +1,42 @@ +import secrets + +from django.conf import settings +from django.contrib.auth.models import AbstractUser +from django.db import models +from django.utils import timezone + + +class User(AbstractUser): + """Plain Django user. Accounts are created with `manage.py createsuperuser`.""" + + def __str__(self): + return self.username + + +class ApiToken(models.Model): + """Bearer token for the Flutter app (the web admin uses session auth).""" + + key = models.CharField(max_length=64, unique=True, editable=False) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="api_tokens" + ) + name = models.CharField( + max_length=100, blank=True, help_text="Which device this token is for." + ) + created_at = models.DateTimeField(auto_now_add=True) + last_used_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return f"{self.user} — {self.name or 'unnamed device'}" + + def save(self, *args, **kwargs): + if not self.key: + self.key = secrets.token_urlsafe(32) + return super().save(*args, **kwargs) + + def touch(self): + self.last_used_at = timezone.now() + self.save(update_fields=["last_used_at"]) diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..9d29afa --- /dev/null +++ b/manage.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" + +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "osiris.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/osiris/__init__.py b/osiris/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/osiris/api.py b/osiris/api.py new file mode 100644 index 0000000..b775d0a --- /dev/null +++ b/osiris/api.py @@ -0,0 +1,24 @@ +from ninja import NinjaAPI + +from accounts.api import router as accounts_router +from accounts.auth import AUTH +from tracker.api import router as tracker_router + +api = NinjaAPI( + title="Osiris API", + version="1.0.0", + description=( + "Medication and pulse tracking for Osiris the cat.\n\n" + "Log in at `/api/auth/login` with username and password to get a bearer " + "token, then send it as `Authorization: Bearer `." + ), + auth=AUTH, +) + +api.add_router("/auth/", accounts_router, tags=["auth"]) +api.add_router("/tracker/", tracker_router, tags=["tracker"]) + + +@api.get("/health", auth=None, tags=["meta"]) +def health(request): + return {"status": "ok"} diff --git a/osiris/asgi.py b/osiris/asgi.py new file mode 100644 index 0000000..21293f8 --- /dev/null +++ b/osiris/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for osiris project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "osiris.settings") + +application = get_asgi_application() diff --git a/osiris/settings.py b/osiris/settings.py new file mode 100644 index 0000000..98b4bc7 --- /dev/null +++ b/osiris/settings.py @@ -0,0 +1,144 @@ +""" +Django settings for osiris project. + +Generated by 'django-admin startproject' using Django 6.0.7. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/6.0/ref/settings/ +""" + +import os +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.environ.get( + "DJANGO_SECRET_KEY", + "django-insecure-&fochdl--0ywn6%mk3d!ac2o8c4-w6ck=-f&l-w)j361prk31-", +) + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.environ.get("DJANGO_DEBUG", "1") == "1" + +ALLOWED_HOSTS = os.environ.get( + "DJANGO_ALLOWED_HOSTS", "127.0.0.1,localhost,0.0.0.0" +).split(",") + +# Needed when reaching the admin from your phone over the LAN or a tunnel. +CSRF_TRUSTED_ORIGINS = [ + o for o in os.environ.get("DJANGO_CSRF_TRUSTED_ORIGINS", "").split(",") if o +] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "accounts", + "tracker", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "osiris.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "osiris.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/6.0/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/6.0/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = os.environ.get("DJANGO_TIME_ZONE", "Europe/Zurich") + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/6.0/howto/static-files/ + +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "static" + +# Default primary key field type +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +AUTH_USER_MODEL = "accounts.User" + +# The admin is the web UI; there is no separate login page and no registration. +LOGIN_URL = "/admin/login/" +LOGIN_REDIRECT_URL = "/admin/" + +# Keep the phone logged in for a year instead of re-prompting every session. +SESSION_COOKIE_AGE = 365 * 24 * 60 * 60 +SESSION_SAVE_EVERY_REQUEST = True diff --git a/osiris/urls.py b/osiris/urls.py new file mode 100644 index 0000000..1940703 --- /dev/null +++ b/osiris/urls.py @@ -0,0 +1,19 @@ +from django.contrib import admin +from django.shortcuts import redirect +from django.urls import include, path + +from osiris.api import api + +admin.site.site_header = "Osiris" +admin.site.site_title = "Osiris" +admin.site.index_title = "Medication & pulse tracking" + +urlpatterns = [ + path("api/", api.urls), + # Sits under /admin/ so it inherits the admin's styling and login, but must + # come before the admin's own catch-all patterns. + path("admin/", include("tracker.urls")), + path("admin/", admin.site.urls), + # The Today page is the app, so send the root straight to it. + path("", lambda request: redirect("tracker:today")), +] diff --git a/osiris/wsgi.py b/osiris/wsgi.py new file mode 100644 index 0000000..e7a87e9 --- /dev/null +++ b/osiris/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for osiris project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "osiris.settings") + +application = get_wsgi_application() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..81fd5d8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "osiris" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.14" +dependencies = [ + "django>=5.2", + "django-ninja>=1.4", +] + +[dependency-groups] +dev = [ + "django-extensions>=4.1", + "ipython>=9.15.0", + "ruff>=0.15.21", +] diff --git a/templates/admin/base_site.html b/templates/admin/base_site.html new file mode 100644 index 0000000..0594374 --- /dev/null +++ b/templates/admin/base_site.html @@ -0,0 +1,16 @@ +{% extends "admin/base.html" %} + +{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Osiris') }}{% endblock %} + +{% block branding %} + +{% if user.is_anonymous %} + {% include "admin/color_theme_toggle.html" %} +{% endif %} +{% endblock %} + +{% block nav-global %} + {% if user.is_active and user.is_staff %} + Today + {% endif %} +{% endblock %} diff --git a/templates/tracker/today.html b/templates/tracker/today.html new file mode 100644 index 0000000..3b77f51 --- /dev/null +++ b/templates/tracker/today.html @@ -0,0 +1,149 @@ +{% extends "admin/base_site.html" %} + +{% block title %}Osiris — {{ day.date|date:"D d M" }}{% endblock %} + +{% block extrastyle %} +{{ block.super }} + +{% endblock %} + +{% block breadcrumbs %}{% endblock %} + +{% block content %} +
+ ← Prev + + {{ day.date|date:"l j F" }} + {% if is_today %}
Today{% endif %} +
+ {% if is_today %} + + {% else %} + Next → + {% endif %} +
+ +
+ {% csrf_token %} + + +
+

Medication

+ {% regroup day.doses by time_of_day_label as dose_groups %} + {% for group in dose_groups %} +
{{ group.grouper }}
+ {% for dose in group.list %} + + {% endfor %} + {% empty %} +

+ No active medication yet. + Add one. +

+ {% endfor %} +
+ +
+

Pulse

+ + +
+ + +
+ +
+

Pulse — last 90 days

+ {% if chart %} + + + + + {% for point in chart.points %} + + {{ point.reading.date }} — {{ point.reading.bpm }} bpm + + {% endfor %} + {{ chart.high }} + {{ chart.low }} + +
+
Average{{ chart.average }} bpm
+
Range{{ chart.low }}–{{ chart.high }}
+
Readings{{ reading_count }}
+
+ Trend + + {% if chart.trend > 0 %}↑ +{{ chart.trend }}{% elif chart.trend < 0 %}↓ {{ chart.trend }}{% else %}→ steady{% endif %} + {% if chart.trend %}bpm/day{% endif %} + +
+
+ {% else %} +

Record the pulse on at least two days to see the graph.

+ {% endif %} +
+ +

Manage medication & history →

+{% endblock %} diff --git a/tracker/__init__.py b/tracker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tracker/admin.py b/tracker/admin.py new file mode 100644 index 0000000..1222121 --- /dev/null +++ b/tracker/admin.py @@ -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" diff --git a/tracker/api.py b/tracker/api.py new file mode 100644 index 0000000..da2daa8 --- /dev/null +++ b/tracker/api.py @@ -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."} diff --git a/tracker/apps.py b/tracker/apps.py new file mode 100644 index 0000000..81b111a --- /dev/null +++ b/tracker/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class TrackerConfig(AppConfig): + name = "tracker" diff --git a/tracker/migrations/0001_initial.py b/tracker/migrations/0001_initial.py new file mode 100644 index 0000000..bd8e8f8 --- /dev/null +++ b/tracker/migrations/0001_initial.py @@ -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" + ), + ), + ] diff --git a/tracker/migrations/__init__.py b/tracker/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tracker/models.py b/tracker/models.py new file mode 100644 index 0000000..0c1d872 --- /dev/null +++ b/tracker/models.py @@ -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" diff --git a/tracker/tests.py b/tracker/tests.py new file mode 100644 index 0000000..4127c71 --- /dev/null +++ b/tracker/tests.py @@ -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, " 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), + }, + ) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..dd9f7ce --- /dev/null +++ b/uv.lock @@ -0,0 +1,417 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "asgiref" +version = "3.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/1e/faf0f247f6f881b98fc4d6d07e14085cb89d13665084e6d6ac1dc2c03d0b/asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2", size = 63136, upload-time = "2026-07-12T03:31:49.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933", size = 28702, upload-time = "2026-07-12T03:31:47.542Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, +] + +[[package]] +name = "django" +version = "6.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/55/664f24ff81c9ea19cb7dfc851afeae1f3c2390c7aee01d4ded68b5c1580d/django-6.0.7.tar.gz", hash = "sha256:2998503fc083124fb58037084bfa00de323c7c743f05f1b4284e77bff0ab8890", size = 10921299, upload-time = "2026-07-07T13:51:26.485Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/ec/1ce5334b6a2c52ce619c23a0be8d366a57a0e080ebb2d88266e5c849157c/django-6.0.7-py3-none-any.whl", hash = "sha256:a037427c2288443a8c02a1b02295a31c239663aa682bc50b1976afb7cf6a769e", size = 8373344, upload-time = "2026-07-07T13:51:20.007Z" }, +] + +[[package]] +name = "django-extensions" +version = "4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/b3/ed0f54ed706ec0b54fd251cc0364a249c6cd6c6ec97f04dc34be5e929eac/django_extensions-4.1.tar.gz", hash = "sha256:7b70a4d28e9b840f44694e3f7feb54f55d495f8b3fa6c5c0e5e12bcb2aa3cdeb", size = 283078, upload-time = "2025-04-11T01:15:39.617Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" }, +] + +[[package]] +name = "django-ninja" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/7c/3307e17b872f545c88314b2737a22f965785dfb5a120d739b0131d0492c3/django_ninja-1.6.2.tar.gz", hash = "sha256:d56ae5aa4791068ef4ac9a66cfdf2fc11f507413ded35abb79c51d0d52ad6412", size = 3685599, upload-time = "2026-03-18T20:06:47.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/0c/25f72060a39632fbd2d90e9c8b6052a09cd45b0598fc06c0758d313f0052/django_ninja-1.6.2-py3-none-any.whl", hash = "sha256:20095f5900bada22ea00cf1a58af50bdb285b2354c61a9d9b47d0dc89ac462d6", size = 2374994, upload-time = "2026-03-18T20:06:45.676Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "ipython" +version = "9.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/3a/948263ca3b9d65bb2b1b0c521b3a49fad5d59ada58724bd87d2bd5ff3f36/ipython-9.15.0-py3-none-any.whl", hash = "sha256:515ad9c3cdf0c932a5a9f6245419e8aba706b7bd03c3e1d3a1c83d9351d6aa6e", size = 630895, upload-time = "2026-06-26T11:03:33.809Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "jedi" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, +] + +[[package]] +name = "osiris" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "django" }, + { name = "django-ninja" }, +] + +[package.dev-dependencies] +dev = [ + { name = "django-extensions" }, + { name = "ipython" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "django", specifier = ">=5.2" }, + { name = "django-ninja", specifier = ">=1.4" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "django-extensions", specifier = ">=4.1" }, + { name = "ipython", specifier = ">=9.15.0" }, + { name = "ruff", specifier = ">=0.15.21" }, +] + +[[package]] +name = "parso" +version = "0.8.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, + { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, + { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, + { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, +] + +[[package]] +name = "sqlparse" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[package]] +name = "traitlets" +version = "5.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344, upload-time = "2026-06-03T12:26:06.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, +]