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:
parent
82bcdde043
commit
950a4030ee
41 changed files with 1156 additions and 4 deletions
9
.dockerignore
Normal file
9
.dockerignore
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
.git
|
||||
.venv
|
||||
.env
|
||||
data
|
||||
staticfiles
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
docs
|
||||
8
.env.example
Normal file
8
.env.example
Normal 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
30
.forgejo/workflows/ci.yml
Normal 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
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.env
|
||||
data/
|
||||
staticfiles/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
3.13
|
||||
36
Dockerfile
Normal file
36
Dockerfile
Normal 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
42
README.md
Normal 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
18
bin/start
Executable 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
18
compose.yaml
Normal 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
0
config/__init__.py
Normal file
0
config/settings/__init__.py
Normal file
0
config/settings/__init__.py
Normal file
118
config/settings/base.py
Normal file
118
config/settings/base.py
Normal 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
5
config/settings/dev.py
Normal 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
34
config/settings/prod.py
Normal 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
8
config/settings/test.py
Normal 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
9
config/urls.py
Normal 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
7
config/wsgi.py
Normal 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()
|
||||
|
|
@ -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
34
fly.toml
Normal 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
0
intent/__init__.py
Normal file
0
intent/accounts/__init__.py
Normal file
0
intent/accounts/__init__.py
Normal file
6
intent/accounts/admin.py
Normal file
6
intent/accounts/admin.py
Normal 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
6
intent/accounts/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
name = "intent.accounts"
|
||||
label = "accounts"
|
||||
121
intent/accounts/migrations/0001_initial.py
Normal file
121
intent/accounts/migrations/0001_initial.py
Normal 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()),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
intent/accounts/migrations/__init__.py
Normal file
0
intent/accounts/migrations/__init__.py
Normal file
9
intent/accounts/models.py
Normal file
9
intent/accounts/models.py
Normal 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
0
intent/core/__init__.py
Normal file
6
intent/core/apps.py
Normal file
6
intent/core/apps.py
Normal 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
16
intent/core/middleware.py
Normal 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
25
intent/core/models.py
Normal 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
5
intent/core/views.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from django.shortcuts import render
|
||||
|
||||
|
||||
def home(request):
|
||||
return render(request, "core/home.html")
|
||||
13
litestream.yml
Normal file
13
litestream.yml
Normal 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
14
manage.py
Executable 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
32
pyproject.toml
Normal 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
67
static/css/intent.css
Normal 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
40
static/js/theme.js
Normal 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
34
templates/base.html
Normal 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
6
templates/core/home.html
Normal 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
0
tests/__init__.py
Normal file
38
tests/test_core.py
Normal file
38
tests/test_core.py
Normal 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
328
uv.lock
generated
Normal 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 },
|
||||
]
|
||||
Loading…
Add table
Reference in a new issue