Scaffold Phase 0: Django project, deploy config, base layout

Django 5.2 with dev/test/prod settings, SQLite tuned for Litestream
(WAL, IMMEDIATE transactions), ULID primary keys via a shared base
model, and a custom User model ahead of the first migration.

Minimal accessible base template with dark mode via color-scheme and
an optional theme picker. /healthz answers before host validation so
Fly checks pass.

Dockerfile (dev and prod targets), compose for local dev, fly.toml
with placeholder app/region, Litestream backups to B2, and a Forgejo
Actions workflow. Migrations run in bin/start because Fly release
machines don't mount volumes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alec K2XAP 2026-10-27 06:59:34 -04:00
parent 82bcdde043
commit 950a4030ee
41 changed files with 1156 additions and 4 deletions

9
.dockerignore Normal file
View file

@ -0,0 +1,9 @@
.git
.venv
.env
data
staticfiles
__pycache__
.pytest_cache
.ruff_cache
docs

8
.env.example Normal file
View file

@ -0,0 +1,8 @@
# Copy to .env for local overrides. Nothing here is required for local development.
# Point uploads at a real bucket (e.g. a Tigris dev bucket) instead of the local filesystem.
# BUCKET_NAME=
# AWS_ACCESS_KEY_ID=
# AWS_SECRET_ACCESS_KEY=
# AWS_ENDPOINT_URL_S3=
# AWS_REGION=auto

30
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,30 @@
name: ci
on:
push:
pull_request:
jobs:
test:
# Adjust to match the labels of your Forgejo runner.
runs-on: docker
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- uses: https://github.com/astral-sh/setup-uv@v6
with:
version: "0.13.6"
- run: uv sync --locked
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run pytest
deploy:
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: docker
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- uses: https://github.com/superfly/flyctl-actions/setup-flyctl@master
- run: flyctl deploy --remote-only
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
__pycache__/
*.pyc
.venv/
.env
data/
staticfiles/
.pytest_cache/
.ruff_cache/

1
.python-version Normal file
View file

@ -0,0 +1 @@
3.13

36
Dockerfile Normal file
View file

@ -0,0 +1,36 @@
# syntax=docker/dockerfile:1
FROM python:3.13-slim-trixie AS base
COPY --from=ghcr.io/astral-sh/uv:0.13.6 /uv /usr/local/bin/uv
ENV PYTHONUNBUFFERED=1 \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=never \
# Outside /app so the compose bind mount doesn't hide it.
UV_PROJECT_ENVIRONMENT=/opt/venv \
PATH="/opt/venv/bin:$PATH"
WORKDIR /app
FROM base AS dev
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project
ENV DJANGO_SETTINGS_MODULE=config.settings.dev
CMD ["sh", "-c", "python manage.py migrate --noinput && python manage.py runserver 0.0.0.0:8000"]
FROM base AS prod
COPY --from=litestream/litestream:0.5.17 /usr/local/bin/litestream /usr/local/bin/litestream
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
COPY litestream.yml /etc/litestream.yml
COPY . .
RUN DJANGO_SETTINGS_MODULE=config.settings.prod DJANGO_SECRET_KEY=collectstatic \
python manage.py collectstatic --noinput
ENV DJANGO_SETTINGS_MODULE=config.settings.prod
EXPOSE 8000
CMD ["bin/start"]

42
README.md Normal file
View file

@ -0,0 +1,42 @@
# Intent
Track the why behind features, across implementations. See [docs/concept.md](docs/concept.md) and [docs/plan.md](docs/plan.md).
## Local development
With uv:
```sh
uv sync
uv run python manage.py migrate
uv run python manage.py runserver
```
Or with Docker:
```sh
docker compose up --build
```
Then open http://localhost:8000. Data lives in `data/` (uv) or the `data` volume (Docker).
Checks:
```sh
uv run ruff check . && uv run ruff format --check . && uv run pytest
```
## Production
Fly.io, one machine with a volume at `/data`. `bin/start` restores the database from B2 if the volume is empty, runs migrations, and starts gunicorn under Litestream.
Secrets (`fly secrets set`):
| Name | Purpose |
|---|---|
| `DJANGO_SECRET_KEY` | Django secret key |
| `LITESTREAM_BUCKET`, `LITESTREAM_ENDPOINT`, `LITESTREAM_ACCESS_KEY_ID`, `LITESTREAM_SECRET_ACCESS_KEY` | B2 backup bucket |
| `BUCKET_NAME`, `AWS_*` | Tigris uploads; set by `fly storage create` |
| `SENTRY_DSN` | Optional error reporting |
Restore drill: create a fresh volume, deploy, and confirm `bin/start` logs a restore before migrating.

18
bin/start Executable file
View file

@ -0,0 +1,18 @@
#!/bin/sh
# Production entrypoint. Migrations run here rather than in Fly's release_command,
# because release machines don't mount the volume that holds the SQLite database.
set -eu
DB="${INTENT_DATA_DIR:?}/db.sqlite3"
SERVER="gunicorn config.wsgi --bind 0.0.0.0:8000 --workers 3 --access-logfile -"
if [ -z "${LITESTREAM_BUCKET:-}" ]; then
echo "LITESTREAM_BUCKET not set: running without backups" >&2
python manage.py migrate --noinput
exec $SERVER
fi
# On a fresh volume, pull the latest backup before anything touches the database.
litestream restore -if-db-not-exists -if-replica-exists "$DB"
python manage.py migrate --noinput
exec litestream replicate -exec "$SERVER"

18
compose.yaml Normal file
View file

@ -0,0 +1,18 @@
services:
web:
build:
context: .
target: dev
ports:
- "8000:8000"
volumes:
- .:/app
- data:/data
environment:
INTENT_DATA_DIR: /data
env_file:
- path: .env
required: false
volumes:
data:

0
config/__init__.py Normal file
View file

View file

118
config/settings/base.py Normal file
View file

@ -0,0 +1,118 @@
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent.parent
# Holds the SQLite database. A Fly volume in production, a Docker volume in compose.
DATA_DIR = Path(os.environ.get("INTENT_DATA_DIR", BASE_DIR / "data"))
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "")
DEBUG = False
ALLOWED_HOSTS = []
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"intent.core",
"intent.accounts",
]
MIDDLEWARE = [
# First, so health checks skip host validation and the HTTPS redirect.
"intent.core.middleware.HealthCheckMiddleware",
"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 = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"
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",
],
},
},
]
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": DATA_DIR / "db.sqlite3",
"OPTIONS": {
# Take the write lock at BEGIN so concurrent writers wait instead of failing.
"transaction_mode": "IMMEDIATE",
"timeout": 5,
# WAL is required by Litestream.
"init_command": "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;",
},
}
}
AUTH_USER_MODEL = "accounts.User"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
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"},
]
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / "static"]
# Uploaded files go to S3-compatible storage (Tigris) when a bucket is configured,
# otherwise to the local filesystem. `fly storage create` sets these variable names.
if os.environ.get("BUCKET_NAME"):
_default_storage = {
"BACKEND": "storages.backends.s3.S3Storage",
"OPTIONS": {
"bucket_name": os.environ["BUCKET_NAME"],
"endpoint_url": os.environ.get("AWS_ENDPOINT_URL_S3"),
"region_name": os.environ.get("AWS_REGION", "auto"),
"default_acl": None,
"querystring_auth": True,
"querystring_expire": 300,
"file_overwrite": False,
},
}
else:
_default_storage = {
"BACKEND": "django.core.files.storage.FileSystemStorage",
"OPTIONS": {"location": DATA_DIR / "media"},
}
STORAGES = {
"default": _default_storage,
"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"},
}
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {"console": {"class": "logging.StreamHandler"}},
"root": {"handlers": ["console"], "level": "INFO"},
}

5
config/settings/dev.py Normal file
View file

@ -0,0 +1,5 @@
from .base import * # noqa: F403
DEBUG = True
SECRET_KEY = SECRET_KEY or "dev-insecure-secret-key" # noqa: F405
ALLOWED_HOSTS = ["localhost", "127.0.0.1", "[::1]"]

34
config/settings/prod.py Normal file
View file

@ -0,0 +1,34 @@
import os
import sentry_sdk
from django.core.exceptions import ImproperlyConfigured
from .base import * # noqa: F403
if not SECRET_KEY: # noqa: F405
raise ImproperlyConfigured("DJANGO_SECRET_KEY must be set")
ALLOWED_HOSTS = [h for h in os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",") if h]
CSRF_TRUSTED_ORIGINS = [f"https://{h}" for h in ALLOWED_HOSTS]
# Fly terminates TLS and forwards the original scheme.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
# Short until the domain is settled; raise to a year after launch.
SECURE_HSTS_SECONDS = 60 * 60 * 24
MIDDLEWARE = [*MIDDLEWARE] # noqa: F405
MIDDLEWARE.insert(
MIDDLEWARE.index("django.middleware.security.SecurityMiddleware") + 1,
"whitenoise.middleware.WhiteNoiseMiddleware",
)
STORAGES = {
**STORAGES, # noqa: F405
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}
if dsn := os.environ.get("SENTRY_DSN"):
sentry_sdk.init(dsn=dsn, send_default_pii=False, traces_sample_rate=0)

8
config/settings/test.py Normal file
View file

@ -0,0 +1,8 @@
from .base import * # noqa: F403
SECRET_KEY = "test-secret-key"
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
STORAGES = {
**STORAGES, # noqa: F405
"default": {"BACKEND": "django.core.files.storage.InMemoryStorage"},
}

9
config/urls.py Normal file
View file

@ -0,0 +1,9 @@
from django.contrib import admin
from django.urls import path
from intent.core import views
urlpatterns = [
path("", views.home, name="home"),
path("admin/", admin.site.urls),
]

7
config/wsgi.py Normal file
View file

@ -0,0 +1,7 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.prod")
application = get_wsgi_application()

View file

@ -51,8 +51,8 @@ Decisions made in the concept are not repeated. These fill the gaps.
| Background work | None. Email is sent synchronously. |
| Observability | Sentry (free tier), Fly logs. |
| Tests | `pytest-django` for models, permissions and views; a handful of Playwright smoke tests for HTMX flows. |
| Local dev | Docker Compose: one app container (Django runserver), SQLite in a volume, MinIO as a stand-in for Tigris. `.env` for secrets. |
| Deploy | Fly.io, one machine, one volume, `release_command` runs migrations, Litestream wraps the app process. CI assumed to be Forgejo Actions in the Intent repo: lint, test, `fly deploy` on main. |
| Local dev | Docker Compose: one app container (Django runserver), SQLite in a volume. Uploads go to the local filesystem unless `BUCKET_NAME` is set, in which case they go to a real bucket (e.g. a Tigris dev bucket). No MinIO: its community images stopped in Oct 2025 and the repo was archived in 2026. `.env` for secrets. |
| Deploy | Fly.io, one machine, one volume. `bin/start` restores from Litestream if the volume is empty, runs migrations, then runs gunicorn under `litestream replicate`. Migrations can't use Fly's `release_command` because release machines don't mount volumes. CI assumed to be Forgejo Actions in the Intent repo: lint, test, `fly deploy` on main. |
### Styling and accessibility
@ -131,9 +131,10 @@ Each phase ends deployable. Rough sizes assume one person, part-time; adjust fre
- Repo, `uv` project, Django 5.2, settings split (base/dev/prod), `ruff`, `pytest-django`.
- Abstract base model with ULID primary key and timestamps; all models inherit it.
- Docker Compose for local dev with MinIO.
- Docker Compose for local dev.
- Custom `User` model with ULID key (must exist before the first migration).
- Base template: semantic layout, skip link, nav, the minimal stylesheet, dark mode toggle.
- Fly app, volume, Litestream to B2, `release_command` for migrations, Sentry.
- Fly app, volume, Litestream to B2, migrations in the start script, Sentry.
- CI: lint + test + deploy on main.
- **Exit:** empty app with a health page running on Fly; restore from Litestream tested once.

34
fly.toml Normal file
View file

@ -0,0 +1,34 @@
# App name and region are placeholders until the Fly app is created.
app = "intent-CHANGEME"
primary_region = "CHANGEME"
[build]
build-target = "prod"
[env]
DJANGO_SETTINGS_MODULE = "config.settings.prod"
DJANGO_ALLOWED_HOSTS = "intent-CHANGEME.fly.dev"
INTENT_DATA_DIR = "/data"
# One machine, one volume: SQLite lives here and Litestream replicates it to B2.
[mounts]
source = "intent_data"
destination = "/data"
[http_service]
internal_port = 8000
force_https = true
auto_stop_machines = "stop"
auto_start_machines = true
min_machines_running = 0
[[http_service.checks]]
method = "GET"
path = "/healthz"
interval = "30s"
timeout = "5s"
grace_period = "20s"
[[vm]]
size = "shared-cpu-1x"
memory = "512mb"

0
intent/__init__.py Normal file
View file

View file

6
intent/accounts/admin.py Normal file
View file

@ -0,0 +1,6 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import User
admin.site.register(User, UserAdmin)

6
intent/accounts/apps.py Normal file
View file

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

View file

@ -0,0 +1,121 @@
# Generated by Django 5.2.18 on 2026-10-27 10:39
import django.contrib.auth.models
import django.contrib.auth.validators
import django.utils.timezone
import intent.core.models
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=[
("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"
),
),
(
"id",
intent.core.models.ULIDField(
default=intent.core.models.new_ulid,
editable=False,
max_length=26,
primary_key=True,
serialize=False,
),
),
(
"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()),
],
),
]

View file

View file

@ -0,0 +1,9 @@
from django.contrib.auth.models import AbstractUser
from intent.core.models import ULIDField
# Defined in Phase 0 because swapping AUTH_USER_MODEL after the first migration is painful.
# Fleshed out (allauth, staff separation) in Phase 1.
class User(AbstractUser):
id = ULIDField(primary_key=True)

0
intent/core/__init__.py Normal file
View file

6
intent/core/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
name = "intent.core"
label = "core"

16
intent/core/middleware.py Normal file
View file

@ -0,0 +1,16 @@
from django.db import connection
from django.http import HttpResponse
class HealthCheckMiddleware:
"""Answer /healthz before host validation, so Fly's checks pass whatever Host they send."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.path == "/healthz":
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
return HttpResponse("ok", content_type="text/plain")
return self.get_response(request)

25
intent/core/models.py Normal file
View file

@ -0,0 +1,25 @@
from django.db import models
from ulid import ULID
def new_ulid() -> str:
return str(ULID())
class ULIDField(models.CharField):
"""Opaque, time-sortable identifier stored as its 26-character Crockford base32 string."""
def __init__(self, *args, **kwargs):
kwargs.setdefault("max_length", 26)
kwargs.setdefault("default", new_ulid)
kwargs.setdefault("editable", False)
super().__init__(*args, **kwargs)
class BaseModel(models.Model):
id = ULIDField(primary_key=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True

5
intent/core/views.py Normal file
View file

@ -0,0 +1,5 @@
from django.shortcuts import render
def home(request):
return render(request, "core/home.html")

13
litestream.yml Normal file
View file

@ -0,0 +1,13 @@
# Continuous SQLite backup to Backblaze B2.
# Explicit keys so Litestream never picks up the AWS_* variables that belong to Tigris.
# Litestream 0.5+ detects B2 endpoints and sets path style and payload signing itself.
access-key-id: ${LITESTREAM_ACCESS_KEY_ID}
secret-access-key: ${LITESTREAM_SECRET_ACCESS_KEY}
dbs:
- path: ${INTENT_DATA_DIR}/db.sqlite3
replica:
type: s3
bucket: ${LITESTREAM_BUCKET}
path: intent/db
endpoint: ${LITESTREAM_ENDPOINT}

14
manage.py Executable file
View file

@ -0,0 +1,14 @@
#!/usr/bin/env python
import os
import sys
def main():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.dev")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

32
pyproject.toml Normal file
View file

@ -0,0 +1,32 @@
[project]
name = "intent"
version = "0.1.0"
description = "Track the why behind features, across implementations."
requires-python = ">=3.13"
dependencies = [
"django>=5.2,<5.3",
"django-storages[s3]>=1.14.6",
"gunicorn>=26.2.0",
"python-ulid>=4.0.1",
"sentry-sdk[django]>=2.73.0",
"whitenoise>=6.12.0",
]
[dependency-groups]
dev = [
"pytest>=9.1.1",
"pytest-django>=4.14.0",
"ruff>=0.17.3",
]
[tool.ruff]
line-length = 100
target-version = "py313"
extend-exclude = ["*/migrations/*"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "DJ"]
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "config.settings.test"
testpaths = ["tests"]

67
static/css/intent.css Normal file
View file

@ -0,0 +1,67 @@
/*
* Intent's whole stylesheet: a thin layer over browser defaults.
* If this file fails to load, layout should suffer but look-and-feel should not.
* Colors come from the browser via color-scheme; no custom palette.
*/
:root {
color-scheme: light dark;
}
:root[data-theme="light"] {
color-scheme: light;
}
:root[data-theme="dark"] {
color-scheme: dark;
}
body {
max-width: 72rem;
margin: 0 auto;
padding: 0 1rem;
line-height: 1.5;
}
header,
footer {
padding-block: 1rem;
}
p,
li,
dd {
max-width: 70ch;
}
img {
max-width: 100%;
height: auto;
}
:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.skip-link {
position: absolute;
left: -100vw;
}
.skip-link:focus {
position: static;
}
.muted {
color: color-mix(in srgb, CanvasText 70%, Canvas);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}

40
static/js/theme.js Normal file
View file

@ -0,0 +1,40 @@
// Three-way theme choice (system, light, dark), stored per browser.
// Loaded in <head> so the saved theme applies before first paint.
(function () {
var KEY = "intent-theme";
var root = document.documentElement;
function read() {
try {
return localStorage.getItem(KEY);
} catch (e) {
return null;
}
}
function write(value) {
try {
if (value === "system") localStorage.removeItem(KEY);
else localStorage.setItem(KEY, value);
} catch (e) {}
}
function apply(value) {
if (value === "light" || value === "dark") root.dataset.theme = value;
else delete root.dataset.theme;
}
apply(read());
document.addEventListener("DOMContentLoaded", function () {
var form = document.getElementById("theme-form");
var select = document.getElementById("theme-select");
if (!form || !select) return;
select.value = root.dataset.theme || "system";
select.addEventListener("change", function () {
apply(select.value);
write(select.value);
});
form.hidden = false;
});
})();

34
templates/base.html Normal file
View file

@ -0,0 +1,34 @@
{% load static %}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>{% block title %}Intent{% endblock %}</title>
{# Blocking on purpose: applies the saved theme before first paint. #}
<script src="{% static 'js/theme.js' %}"></script>
<link rel="stylesheet" href="{% static 'css/intent.css' %}">
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<header>
<nav aria-label="Main">
<a href="{% url 'home' %}">Intent</a>
</nav>
</header>
<main id="main" tabindex="-1">
{% block content %}{% endblock %}
</main>
<footer>
{# Shown by theme.js; without JS the system preference applies. #}
<form id="theme-form" hidden>
<label for="theme-select">Theme</label>
<select id="theme-select" name="theme">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</form>
</footer>
</body>
</html>

6
templates/core/home.html Normal file
View file

@ -0,0 +1,6 @@
{% extends "base.html" %}
{% block content %}
<h1>Intent</h1>
<p>Track the why behind features, across every implementation.</p>
{% endblock %}

0
tests/__init__.py Normal file
View file

38
tests/test_core.py Normal file
View file

@ -0,0 +1,38 @@
import pytest
from django.contrib.auth import get_user_model
from ulid import ULID
from intent.core.models import new_ulid
def test_new_ulid_is_parseable_and_unique():
values = {new_ulid() for _ in range(1000)}
assert len(values) == 1000
for value in values:
assert len(value) == 26
assert str(ULID.from_str(value)) == value
@pytest.mark.django_db
def test_user_primary_key_is_ulid():
user = get_user_model().objects.create_user(username="ada")
assert len(user.pk) == 26
ULID.from_str(user.pk)
@pytest.mark.django_db
def test_healthz_ignores_host_validation(client, settings):
settings.ALLOWED_HOSTS = ["intent.example"]
response = client.get("/healthz", HTTP_HOST="172.19.0.2:8000")
assert response.status_code == 200
assert response.content == b"ok"
def test_home_has_accessible_shell(client):
response = client.get("/")
assert response.status_code == 200
html = response.content.decode()
assert '<html lang="en">' in html
assert '<meta name="color-scheme" content="light dark">' in html
assert 'href="#main"' in html
assert '<main id="main"' in html

328
uv.lock generated Normal file
View file

@ -0,0 +1,328 @@
version = 1
requires-python = ">=3.13"
[[package]]
name = "asgiref"
version = "3.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/90/f5fce14e95fccf53b58e8e2c47cd84d40d105d64073ae1ac75166db150e0/asgiref-3.13.0.tar.gz", hash = "sha256:034cc8cbf72d00553b4be256703ac545120b4e50af13b49dec99da899aeeecb4", size = 52941 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/b0/ec211e1f7966247cb6469158d856bac9aa4c2fe13621bd6a352e4f850453/asgiref-3.13.0-py3-none-any.whl", hash = "sha256:f9596a2434512686473d50f96ff6207fbba49ae1a785dfac614df4aeb085674d", size = 27593 },
]
[[package]]
name = "boto3"
version = "1.43.119"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/02/1b/97f8ec6ce00ae49455797b8af9fde522624ecf80bc73c937596b558b1853/boto3-1.43.119.tar.gz", hash = "sha256:e12c3b1d81713e14bd6947b7412d9d0ea49b320642d795ded1d64d6483ca6f49", size = 112659 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/d7/6c82dff652082a4bab907b7a5833933a362f12400f14bd7a9935a330145e/boto3-1.43.119-py3-none-any.whl", hash = "sha256:d1718edae48c3241d289c0ece8a515399a7242c406e35328b9685c4d24039186", size = 140045 },
]
[[package]]
name = "botocore"
version = "1.43.119"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/ab/8e6d1d97c086c127f725cdeffae3b44d92ca3ca40741fb90f245f11887d5/botocore-1.43.119.tar.gz", hash = "sha256:ed11934be6e6d398ac21b7fe03778c9584368eeff8ba5371581f19bb47db0b6a", size = 16457548 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/54/04e5950c7f2d22d78f22db00ca119b257b7cd2024328b6116ebaeb9c14eb/botocore-1.43.119-py3-none-any.whl", hash = "sha256:6026aec97914a4fa116915fd3b05af899912eff51b93fb8dff95aa228a3e761b", size = 16154402 },
]
[[package]]
name = "certifi"
version = "2026.10.23"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082 },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]
[[package]]
name = "django"
version = "5.2.18"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/85/fe/79e692b430c7721bf4b812c328bfe687e73e3f28f56611a6a18a97e887cc/django-5.2.18.tar.gz", hash = "sha256:461c5dd06d2ea16bd5ca37d3f46e4def1d6b0fe7588c6f4e2119517bb0af8b2d", size = 10910238 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/01/6568ec52b26548ca1b83d33249f7274db3bf74698f42c0da42ec9db1a24a/django-5.2.18-py3-none-any.whl", hash = "sha256:92ed81d500be6408ecd704d7bd1366c534f30427bffcc63c5fefb129561aec7c", size = 8316071 },
]
[[package]]
name = "django-storages"
version = "1.14.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ff/d6/2e50e378fff0408d558f36c4acffc090f9a641fd6e084af9e54d45307efa/django_storages-1.14.6.tar.gz", hash = "sha256:7a25ce8f4214f69ac9c7ce87e2603887f7ae99326c316bc8d2d75375e09341c9", size = 87587 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/21/3cedee63417bc5553eed0c204be478071c9ab208e5e259e97287590194f1/django_storages-1.14.6-py3-none-any.whl", hash = "sha256:11b7b6200e1cb5ffcd9962bd3673a39c7d6a6109e8096f0e03d46fab3d3aabd9", size = 33095 },
]
[package.optional-dependencies]
s3 = [
{ name = "boto3" },
]
[[package]]
name = "gunicorn"
version = "26.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d9/8a/e4ef6ee11701b6cd64702848415ffb69eeff85cb388a3c6c7fe86f22f3f8/gunicorn-26.2.0.tar.gz", hash = "sha256:62b864895d9ebff0b2f9867ba04fe811c93121596540830c9c916d0769668447", size = 787921 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/85/7522a52e5e2f42faf1a129113ab63e548c42e103e9af395b7bfe65e403e2/gunicorn-26.2.0-py3-none-any.whl", hash = "sha256:bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3", size = 228389 },
]
[[package]]
name = "iniconfig"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552 },
]
[[package]]
name = "intent"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "django" },
{ name = "django-storages", extra = ["s3"] },
{ name = "gunicorn" },
{ name = "python-ulid" },
{ name = "sentry-sdk", extra = ["django"] },
{ name = "whitenoise" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-django" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "django", specifier = ">=5.2,<5.3" },
{ name = "django-storages", extras = ["s3"], specifier = ">=1.14.6" },
{ name = "gunicorn", specifier = ">=26.2.0" },
{ name = "python-ulid", specifier = ">=4.0.1" },
{ name = "sentry-sdk", extras = ["django"], specifier = ">=2.73.0" },
{ name = "whitenoise", specifier = ">=6.12.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=9.1.1" },
{ name = "pytest-django", specifier = ">=4.14.0" },
{ name = "ruff", specifier = ">=0.17.3" },
]
[[package]]
name = "jmespath"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419 },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956 },
]
[[package]]
name = "pluggy"
version = "1.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082 },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147 },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536 },
]
[[package]]
name = "pytest-django"
version = "4.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/f6/3851312120c2bf2f19cafff931e75059aad1ba670703cd751e2fde9bc942/pytest_django-4.14.0.tar.gz", hash = "sha256:26787dd3f422cfbab8f55b80a776e2edea7a11092cb74e960bef1312515708ef", size = 94700 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/03/850bffad2b581c440ca51c039d74504d5a422c94bda0bdb8a8ba5068d48b/pytest_django-4.14.0-py3-none-any.whl", hash = "sha256:c533b08d89cc675efcd5398eea270b34547e35f9a3608e2c9748dd88428ea187", size = 27067 },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 },
]
[[package]]
name = "python-ulid"
version = "4.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/41/65079023c81491a21799c0120bce5925366b6913596bf797806f19973290/python_ulid-4.0.1.tar.gz", hash = "sha256:bbeec02556190bb9dc3401faa7268696acbfbe7b6db9908c155dc3548629f20c", size = 101683 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/15/8b39b36f55b6618ec4ca9b55134dcfd9c04cecbc72709f5d8e6bdebed9cd/python_ulid-4.0.1-py3-none-any.whl", hash = "sha256:6f1d69ceb97e99fe542df8476ebcd7a668284bf53ee14b3106bcc6a341a95ed9", size = 14609 },
]
[[package]]
name = "ruff"
version = "0.17.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522 },
{ url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786 },
{ url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362 },
{ url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281 },
{ url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543 },
{ url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921 },
{ url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408 },
{ url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469 },
{ url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491 },
{ url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748 },
{ url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057 },
{ url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431 },
{ url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903 },
{ url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780 },
{ url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481 },
{ url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333 },
{ url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332 },
]
[[package]]
name = "s3transfer"
version = "0.19.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/c5/0a5ce655109cb96b8dfad36908a4e108d15f5dc2eff8fbc3146c4312e704/s3transfer-0.19.3.tar.gz", hash = "sha256:8326acc3d056d7f7b8a37110c7d2aea41f67e83f6ae79cd3b5fbcca34c726406", size = 166087 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e2/a4/88f9e10498759121060b8050a1554753dfc04df39bbbcc7aad9ecc8d9440/s3transfer-0.19.3-py3-none-any.whl", hash = "sha256:24eb7535804306355e31c952a90022bf5b2f9d760f9a11062c7493a217c08553", size = 90252 },
]
[[package]]
name = "sentry-sdk"
version = "2.73.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c5/3b/1e47acf2456781c26e12007379ecc154bf310b8f64d40390d78b791d55c3/sentry_sdk-2.73.0.tar.gz", hash = "sha256:4a2ab97b16e4693c325ab361c7bbbb346dbc7d0bc0150922d795a25bac8d5280", size = 1079078 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/88/cabf98d017d7b940720e514e5f8ee02e3e6f7495caf93c30735a38387ec4/sentry_sdk-2.73.0-py3-none-any.whl", hash = "sha256:0bcfa6928ea5adbb89591858b514d27adac27c7a3cccc31b20ed10e0b09eda9e", size = 549011 },
]
[package.optional-dependencies]
django = [
{ name = "django" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 },
]
[[package]]
name = "sqlparse"
version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/3f06a1006f2261d1342aefb3c71eed02f5d4ca5bdbecd86ebc12ad38306e/sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9", size = 178477 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070 },
]
[[package]]
name = "tzdata"
version = "2026.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce71b6e50a9d09445aa2ecbd08359a337976627b8409e7/tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7", size = 200404 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996 },
]
[[package]]
name = "urllib3"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717 },
]
[[package]]
name = "whitenoise"
version = "6.12.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302 },
]