first version

This commit is contained in:
Loïc Gremaud 2026-07-14 20:10:17 +02:00
commit 4040b4fdd9
Signed by: Legrems
GPG Key ID: D4620E6DF3E0121D
32 changed files with 2176 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
# Django
db.sqlite3
static/
.env

1
.python-version Normal file
View File

@ -0,0 +1 @@
3.14

99
README.md Normal file
View File

@ -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 <http://127.0.0.1:8000/> — 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 <token>` 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 .
```

0
accounts/__init__.py Normal file
View File

18
accounts/admin.py Normal file
View File

@ -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"]

50
accounts/api.py Normal file
View File

@ -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

5
accounts/apps.py Normal file
View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
name = "accounts"

29
accounts/auth.py Normal file
View File

@ -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 <key>` — 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]

View File

@ -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"],
},
),
]

View File

42
accounts/models.py Normal file
View File

@ -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"])

23
manage.py Executable file
View File

@ -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()

0
osiris/__init__.py Normal file
View File

24
osiris/api.py Normal file
View File

@ -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 <token>`."
),
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"}

16
osiris/asgi.py Normal file
View File

@ -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()

144
osiris/settings.py Normal file
View File

@ -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

19
osiris/urls.py Normal file
View File

@ -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")),
]

16
osiris/wsgi.py Normal file
View File

@ -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()

17
pyproject.toml Normal file
View File

@ -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",
]

View File

@ -0,0 +1,16 @@
{% extends "admin/base.html" %}
{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Osiris') }}{% endblock %}
{% block branding %}
<div id="site-name"><a href="{% url 'admin:index' %}">{{ site_header|default:_('Osiris') }}</a></div>
{% if user.is_anonymous %}
{% include "admin/color_theme_toggle.html" %}
{% endif %}
{% endblock %}
{% block nav-global %}
{% if user.is_active and user.is_staff %}
<a href="{% url 'tracker:today' %}" style="color:inherit;font-weight:600;margin-left:12px">Today</a>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,149 @@
{% extends "admin/base_site.html" %}
{% block title %}Osiris — {{ day.date|date:"D d M" }}{% endblock %}
{% block extrastyle %}
{{ block.super }}
<style>
/* Mobile-first: big touch targets, single column, no horizontal scrolling. */
#content { padding: 12px; max-width: 640px; margin: 0 auto; }
.osiris-card {
background: var(--body-bg);
border: 1px solid var(--hairline-color);
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
}
.osiris-daynav {
display: flex; align-items: center; justify-content: space-between;
gap: 8px; margin-bottom: 16px;
}
.osiris-daynav a, .osiris-daynav strong { font-size: 1rem; }
.osiris-daynav .today-label { text-align: center; flex: 1; }
.osiris-dose {
display: flex; align-items: center; gap: 14px;
padding: 14px 4px;
border-bottom: 1px solid var(--hairline-color);
cursor: pointer;
}
.osiris-dose:last-of-type { border-bottom: none; }
.osiris-dose input[type="checkbox"] {
width: 28px; height: 28px; flex: none; margin: 0;
}
.osiris-dose .dose-name { font-weight: 600; }
.osiris-dose .dose-detail { color: var(--body-quiet-color); font-size: 0.85rem; }
.osiris-dose.is-taken .dose-name { color: var(--object-tools-bg, #417690); }
.osiris-timeofday {
text-transform: uppercase; font-size: 0.75rem; letter-spacing: 0.06em;
color: var(--body-quiet-color); margin: 16px 0 4px;
}
.osiris-pulse input[type="number"] {
font-size: 1.6rem; width: 100%; padding: 12px;
border-radius: 8px; border: 1px solid var(--border-color);
}
.osiris-save {
width: 100%; padding: 16px; font-size: 1.05rem; font-weight: 600;
border: none; border-radius: 10px; cursor: pointer;
background: var(--button-bg); color: var(--button-fg);
}
.osiris-stats { display: flex; gap: 18px; flex-wrap: wrap; margin-top: 12px; }
.osiris-stats div { font-size: 0.85rem; color: var(--body-quiet-color); }
.osiris-stats strong { display: block; font-size: 1.15rem; color: var(--body-fg); }
.osiris-chart { width: 100%; height: auto; overflow: visible; }
.osiris-empty { color: var(--body-quiet-color); font-style: italic; }
</style>
{% endblock %}
{% block breadcrumbs %}{% endblock %}
{% block content %}
<div class="osiris-daynav">
<a href="?date={{ previous_date|date:'Y-m-d' }}">&larr; Prev</a>
<span class="today-label">
<strong>{{ day.date|date:"l j F" }}</strong>
{% if is_today %}<br><small>Today</small>{% endif %}
</span>
{% if is_today %}
<span></span>
{% else %}
<a href="?date={{ next_date|date:'Y-m-d' }}">Next &rarr;</a>
{% endif %}
</div>
<form method="post">
{% csrf_token %}
<input type="hidden" name="date" value="{{ day.date|date:'Y-m-d' }}">
<div class="osiris-card">
<h2 style="margin-top:0">Medication</h2>
{% regroup day.doses by time_of_day_label as dose_groups %}
{% for group in dose_groups %}
<div class="osiris-timeofday">{{ group.grouper }}</div>
{% for dose in group.list %}
<label class="osiris-dose {% if dose.taken %}is-taken{% endif %}">
<input type="checkbox" name="dose-{{ dose.id }}" {% if dose.taken %}checked{% endif %}>
<span>
<span class="dose-name">{{ dose.medication_name }}</span><br>
<span class="dose-detail">{{ dose.amount }}{% if dose.taken is None %} — not recorded{% endif %}</span>
</span>
</label>
{% endfor %}
{% empty %}
<p class="osiris-empty">
No active medication yet.
<a href="{% url 'admin:tracker_medication_add' %}">Add one</a>.
</p>
{% endfor %}
</div>
<div class="osiris-card osiris-pulse">
<h2 style="margin-top:0">Pulse</h2>
<label for="bpm" class="dose-detail">Beats per minute (leave empty to clear)</label>
<input type="number" id="bpm" name="bpm" inputmode="numeric" min="20" max="400"
placeholder="e.g. 180" value="{{ day.pulse.bpm|default_if_none:'' }}">
</div>
<button type="submit" class="osiris-save">Save {{ day.date|date:"D j M" }}</button>
</form>
<div class="osiris-card" style="margin-top:16px">
<h2 style="margin-top:0">Pulse — last 90 days</h2>
{% if chart %}
<svg class="osiris-chart" viewBox="0 0 {{ chart.width }} {{ chart.height }}" role="img"
aria-label="Pulse from {{ chart.first_date }} to {{ chart.last_date }}">
<line x1="24" y1="24" x2="{{ chart.width|add:'-24' }}" y2="24"
stroke="currentColor" stroke-width="0.5" opacity="0.2"/>
<line x1="24" y1="{{ chart.height|add:'-24' }}" x2="{{ chart.width|add:'-24' }}"
y2="{{ chart.height|add:'-24' }}" stroke="currentColor" stroke-width="0.5" opacity="0.2"/>
<polyline points="{{ chart.polyline }}" fill="none" stroke="#79aec8" stroke-width="2"
stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/>
{% for point in chart.points %}
<circle cx="{{ point.x }}" cy="{{ point.y }}" r="2" fill="#417690">
<title>{{ point.reading.date }} — {{ point.reading.bpm }} bpm</title>
</circle>
{% endfor %}
<text x="2" y="27" font-size="8" fill="currentColor" opacity="0.6">{{ chart.high }}</text>
<text x="2" y="{{ chart.height|add:'-21' }}" font-size="8" fill="currentColor" opacity="0.6">{{ chart.low }}</text>
</svg>
<div class="osiris-stats">
<div>Average<strong>{{ chart.average }} bpm</strong></div>
<div>Range<strong>{{ chart.low }}{{ chart.high }}</strong></div>
<div>Readings<strong>{{ reading_count }}</strong></div>
<div>
Trend
<strong>
{% if chart.trend > 0 %}&uarr; +{{ chart.trend }}{% elif chart.trend < 0 %}&darr; {{ chart.trend }}{% else %}&rarr; steady{% endif %}
{% if chart.trend %}<span style="font-size:0.75rem">bpm/day</span>{% endif %}
</strong>
</div>
</div>
{% else %}
<p class="osiris-empty">Record the pulse on at least two days to see the graph.</p>
{% endif %}
</div>
<p style="text-align:center"><a href="{% url 'admin:index' %}">Manage medication &amp; history &rarr;</a></p>
{% endblock %}

0
tracker/__init__.py Normal file
View File

48
tracker/admin.py Normal file
View File

@ -0,0 +1,48 @@
from django.contrib import admin
from tracker.models import DoseLog, Medication, PulseReading, ScheduledDose
class ScheduledDoseInline(admin.TabularInline):
"""Edit a medication's daily doses right on the medication page."""
model = ScheduledDose
extra = 1
@admin.register(Medication)
class MedicationAdmin(admin.ModelAdmin):
list_display = ["name", "is_active", "schedule_summary"]
list_filter = ["is_active"]
search_fields = ["name"]
inlines = [ScheduledDoseInline]
@admin.display(description="Schedule")
def schedule_summary(self, obj: Medication) -> str:
doses = obj.scheduled_doses.filter(is_active=True)
if not doses:
return ""
return ", ".join(
f"{d.amount} {d.get_time_of_day_display().lower()}" for d in doses
)
@admin.register(ScheduledDose)
class ScheduledDoseAdmin(admin.ModelAdmin):
list_display = ["medication", "time_of_day", "amount", "is_active"]
list_filter = ["is_active", "time_of_day", "medication"]
@admin.register(DoseLog)
class DoseLogAdmin(admin.ModelAdmin):
"""The history. Day-to-day ticking off happens on the Today page."""
list_display = ["date", "scheduled_dose", "taken", "taken_at"]
list_filter = ["taken", "date", "scheduled_dose__medication"]
date_hierarchy = "date"
@admin.register(PulseReading)
class PulseReadingAdmin(admin.ModelAdmin):
list_display = ["date", "bpm", "notes"]
date_hierarchy = "date"

243
tracker/api.py Normal file
View File

@ -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."}

5
tracker/apps.py Normal file
View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class TrackerConfig(AppConfig):
name = "tracker"

View File

@ -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"
),
),
]

View File

107
tracker/models.py Normal file
View File

@ -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"

231
tracker/tests.py Normal file
View File

@ -0,0 +1,231 @@
from datetime import timedelta
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from accounts.models import User
from tracker.models import DoseLog, Medication, PulseReading, ScheduledDose, TimeOfDay
class TrackerTestCase(TestCase):
def setUp(self):
self.user = User.objects.create_superuser("loic", password="hunter2hunter2")
# Medication 1: 1/4 every morning.
self.vetmedin = Medication.objects.create(name="Vetmedin")
self.morning_only = ScheduledDose.objects.create(
medication=self.vetmedin, time_of_day=TimeOfDay.MORNING, amount="1/4"
)
# Medication 2: 1/4 morning and 1/4 evening.
self.fortekor = Medication.objects.create(name="Fortekor")
self.morning = ScheduledDose.objects.create(
medication=self.fortekor, time_of_day=TimeOfDay.MORNING, amount="1/4"
)
self.evening = ScheduledDose.objects.create(
medication=self.fortekor, time_of_day=TimeOfDay.EVENING, amount="1/4"
)
def token(self) -> str:
response = self.client.post(
"/api/auth/login",
{"username": "loic", "password": "hunter2hunter2"},
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
return response.json()["token"]
def auth_header(self) -> dict:
return {"HTTP_AUTHORIZATION": f"Bearer {self.token()}"}
class AuthTests(TrackerTestCase):
def test_login_rejects_a_bad_password(self):
response = self.client.post(
"/api/auth/login",
{"username": "loic", "password": "wrong"},
content_type="application/json",
)
self.assertEqual(response.status_code, 401)
def test_endpoints_require_authentication(self):
self.assertEqual(self.client.get("/api/tracker/day").status_code, 401)
def test_health_is_public(self):
self.assertEqual(self.client.get("/api/health").status_code, 200)
class DoseApiTests(TrackerTestCase):
def test_day_lists_every_scheduled_dose_unrecorded(self):
response = self.client.get("/api/tracker/day", **self.auth_header())
self.assertEqual(response.status_code, 200)
doses = response.json()["doses"]
self.assertEqual(len(doses), 3)
self.assertTrue(all(dose["taken"] is None for dose in doses))
# Morning doses come before the evening one.
self.assertEqual([d["time_of_day"] for d in doses], [1, 1, 3])
def test_logging_a_dose_marks_only_that_dose(self):
response = self.client.post(
"/api/tracker/doses",
{"scheduled_dose_id": self.morning.id, "taken": True},
content_type="application/json",
**self.auth_header(),
)
self.assertEqual(response.status_code, 200)
doses = {d["id"]: d["taken"] for d in response.json()["doses"]}
self.assertIs(doses[self.morning.id], True)
self.assertIsNone(doses[self.evening.id])
self.assertIsNone(doses[self.morning_only.id])
def test_logging_the_same_dose_twice_updates_in_place(self):
for taken in (True, False):
self.client.post(
"/api/tracker/doses",
{"scheduled_dose_id": self.morning.id, "taken": taken},
content_type="application/json",
**self.auth_header(),
)
log = DoseLog.objects.get(scheduled_dose=self.morning)
self.assertFalse(log.taken)
self.assertIsNone(log.taken_at)
self.assertEqual(DoseLog.objects.count(), 1)
def test_a_dose_can_be_logged_for_a_past_day(self):
yesterday = timezone.localdate() - timedelta(days=1)
self.client.post(
"/api/tracker/doses",
{
"scheduled_dose_id": self.evening.id,
"date": yesterday.isoformat(),
"taken": True,
},
content_type="application/json",
**self.auth_header(),
)
self.assertTrue(DoseLog.objects.filter(date=yesterday, taken=True).exists())
self.assertFalse(DoseLog.objects.filter(date=timezone.localdate()).exists())
def test_inactive_medication_drops_out_of_the_day(self):
self.fortekor.is_active = False
self.fortekor.save()
response = self.client.get("/api/tracker/day", **self.auth_header())
self.assertEqual(len(response.json()["doses"]), 1)
class PulseApiTests(TrackerTestCase):
def test_recording_the_pulse_twice_in_a_day_overwrites(self):
for bpm in (180, 192):
response = self.client.post(
"/api/tracker/pulse",
{"bpm": bpm},
content_type="application/json",
**self.auth_header(),
)
self.assertEqual(response.status_code, 200)
self.assertEqual(PulseReading.objects.count(), 1)
self.assertEqual(PulseReading.objects.get().bpm, 192)
def test_an_implausible_pulse_is_rejected(self):
response = self.client.post(
"/api/tracker/pulse",
{"bpm": 5},
content_type="application/json",
**self.auth_header(),
)
self.assertEqual(response.status_code, 422)
def test_stats_report_a_rising_trend(self):
today = timezone.localdate()
for offset, bpm in enumerate([160, 170, 180, 190]):
PulseReading.objects.create(
date=today - timedelta(days=3 - offset), bpm=bpm
)
stats = self.client.get("/api/tracker/pulse/stats", **self.auth_header()).json()
self.assertEqual(stats["count"], 4)
self.assertEqual(stats["minimum"], 160)
self.assertEqual(stats["maximum"], 190)
self.assertEqual(stats["average"], 175.0)
self.assertEqual(stats["trend_bpm_per_day"], 10.0)
def test_stats_are_empty_without_readings(self):
stats = self.client.get("/api/tracker/pulse/stats", **self.auth_header()).json()
self.assertEqual(stats["count"], 0)
self.assertIsNone(stats["average"])
self.assertIsNone(stats["trend_bpm_per_day"])
class TodayPageTests(TrackerTestCase):
def test_the_page_requires_login(self):
response = self.client.get(reverse("tracker:today"))
self.assertEqual(response.status_code, 302)
self.assertIn("/admin/login/", response["Location"])
def test_the_page_lists_the_doses(self):
self.client.force_login(self.user)
response = self.client.get(reverse("tracker:today"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Vetmedin")
self.assertContains(response, "Fortekor")
self.assertContains(response, "Morning")
self.assertContains(response, "Evening")
# Nothing ticked yet, so every dose reads as unrecorded rather than missed.
self.assertContains(response, "not recorded", count=3)
self.assertNotContains(response, "checked")
def test_saving_ticks_doses_and_records_the_pulse(self):
self.client.force_login(self.user)
today = timezone.localdate()
response = self.client.post(
reverse("tracker:today"),
{
"date": today.isoformat(),
f"dose-{self.morning.id}": "on",
f"dose-{self.morning_only.id}": "on",
# The evening checkbox is absent: not given.
"bpm": "188",
},
)
self.assertEqual(response.status_code, 302)
self.assertTrue(DoseLog.objects.get(scheduled_dose=self.morning).taken)
self.assertTrue(DoseLog.objects.get(scheduled_dose=self.morning_only).taken)
self.assertFalse(DoseLog.objects.get(scheduled_dose=self.evening).taken)
self.assertEqual(PulseReading.objects.get(date=today).bpm, 188)
def test_clearing_the_pulse_field_removes_the_reading(self):
self.client.force_login(self.user)
today = timezone.localdate()
PulseReading.objects.create(date=today, bpm=200)
self.client.post(
reverse("tracker:today"), {"date": today.isoformat(), "bpm": ""}
)
self.assertFalse(PulseReading.objects.filter(date=today).exists())
def test_the_graph_appears_once_there_are_two_readings(self):
self.client.force_login(self.user)
today = timezone.localdate()
response = self.client.get(reverse("tracker:today"))
self.assertContains(response, "at least two days")
PulseReading.objects.create(date=today - timedelta(days=1), bpm=170)
PulseReading.objects.create(date=today, bpm=190)
response = self.client.get(reverse("tracker:today"))
self.assertContains(response, "<polyline")
self.assertContains(response, "180.0 bpm") # average

9
tracker/urls.py Normal file
View File

@ -0,0 +1,9 @@
from django.urls import path
from tracker import views
app_name = "tracker"
urlpatterns = [
path("today/", views.today, name="today"),
]

107
tracker/views.py Normal file
View File

@ -0,0 +1,107 @@
from datetime import timedelta
from typing import Optional
from django.contrib import messages
from django.contrib.admin.views.decorators import staff_member_required
from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils import timezone
from django.utils.dateparse import parse_date
from tracker.api import build_day, pulse_trend
from tracker.models import DoseLog, PulseReading, ScheduledDose
CHART_WIDTH = 320
CHART_HEIGHT = 140
CHART_PADDING = 24
def build_chart(readings: list[PulseReading]) -> Optional[dict]:
"""Lay the readings out as SVG coordinates, so the page needs no JavaScript."""
if len(readings) < 2:
return None
bpms = [r.bpm for r in readings]
low, high = min(bpms), max(bpms)
# A flat line would divide by zero, and a narrow range exaggerates noise.
span = max(high - low, 10)
mid = (high + low) / 2
low, high = mid - span / 2, mid + span / 2
first_day = readings[0].date
total_days = max((readings[-1].date - first_day).days, 1)
inner_width = CHART_WIDTH - 2 * CHART_PADDING
inner_height = CHART_HEIGHT - 2 * CHART_PADDING
points = []
for reading in readings:
x = CHART_PADDING + (reading.date - first_day).days / total_days * inner_width
y = CHART_PADDING + (high - reading.bpm) / (high - low) * inner_height
points.append({"x": round(x, 1), "y": round(y, 1), "reading": reading})
return {
"points": points,
"polyline": " ".join(f"{p['x']},{p['y']}" for p in points),
"low": round(low),
"high": round(high),
"average": round(sum(bpms) / len(bpms), 1),
"trend": pulse_trend(readings),
"width": CHART_WIDTH,
"height": CHART_HEIGHT,
"first_date": first_day,
"last_date": readings[-1].date,
}
@staff_member_required
def today(request):
"""The main screen: tick off the day's doses and record the pulse."""
day = parse_date(request.GET.get("date", "")) or timezone.localdate()
if request.method == "POST":
day = parse_date(request.POST.get("date", "")) or timezone.localdate()
doses = ScheduledDose.objects.filter(is_active=True, medication__is_active=True)
for dose in doses:
taken = f"dose-{dose.id}" in request.POST
log, _ = DoseLog.objects.get_or_create(
scheduled_dose=dose, date=day, defaults={"taken": taken}
)
log.taken = taken
log.save()
bpm = (request.POST.get("bpm") or "").strip()
if bpm:
try:
PulseReading.objects.update_or_create(
date=day, defaults={"bpm": int(bpm)}
)
except ValueError:
messages.error(request, f"'{bpm}' is not a valid pulse.")
else:
# Clearing the field removes the reading for that day.
PulseReading.objects.filter(date=day).delete()
messages.success(request, f"Saved for {day:%A %d %B}.")
return redirect(f"{reverse('tracker:today')}?date={day.isoformat()}")
readings = list(
PulseReading.objects.filter(
date__gte=timezone.localdate() - timedelta(days=90)
).order_by("date")
)
return render(
request,
"tracker/today.html",
{
"title": "Today",
"day": build_day(day),
"is_today": day == timezone.localdate(),
"previous_date": day - timedelta(days=1),
"next_date": day + timedelta(days=1),
"chart": build_chart(readings),
"reading_count": len(readings),
},
)

417
uv.lock Normal file
View File

@ -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" },
]