diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..66b12ad --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.venv +.env +data +staticfiles +__pycache__ +.pytest_cache +.ruff_cache +docs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fd9ba5f --- /dev/null +++ b/.env.example @@ -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 diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..c6de86b --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -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 }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..645e583 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +.venv/ +.env +data/ +staticfiles/ +.pytest_cache/ +.ruff_cache/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ae52bc7 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f7b2ad9 --- /dev/null +++ b/README.md @@ -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. diff --git a/bin/start b/bin/start new file mode 100755 index 0000000..5de1914 --- /dev/null +++ b/bin/start @@ -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" diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..42db7cb --- /dev/null +++ b/compose.yaml @@ -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: diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/settings/__init__.py b/config/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/settings/base.py b/config/settings/base.py new file mode 100644 index 0000000..4577b3b --- /dev/null +++ b/config/settings/base.py @@ -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"}, +} diff --git a/config/settings/dev.py b/config/settings/dev.py new file mode 100644 index 0000000..0d7099a --- /dev/null +++ b/config/settings/dev.py @@ -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]"] diff --git a/config/settings/prod.py b/config/settings/prod.py new file mode 100644 index 0000000..535ee6b --- /dev/null +++ b/config/settings/prod.py @@ -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) diff --git a/config/settings/test.py b/config/settings/test.py new file mode 100644 index 0000000..9567e0f --- /dev/null +++ b/config/settings/test.py @@ -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"}, +} diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..f52b438 --- /dev/null +++ b/config/urls.py @@ -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), +] diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..3f4f40c --- /dev/null +++ b/config/wsgi.py @@ -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() diff --git a/docs/plan.md b/docs/plan.md index fe92ee1..6446879 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -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. diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000..9a142ad --- /dev/null +++ b/fly.toml @@ -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" diff --git a/intent/__init__.py b/intent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/intent/accounts/__init__.py b/intent/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/intent/accounts/admin.py b/intent/accounts/admin.py new file mode 100644 index 0000000..6d53b53 --- /dev/null +++ b/intent/accounts/admin.py @@ -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) diff --git a/intent/accounts/apps.py b/intent/accounts/apps.py new file mode 100644 index 0000000..8b79ab6 --- /dev/null +++ b/intent/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + name = "intent.accounts" + label = "accounts" diff --git a/intent/accounts/migrations/0001_initial.py b/intent/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..aa1e31b --- /dev/null +++ b/intent/accounts/migrations/0001_initial.py @@ -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()), + ], + ), + ] diff --git a/intent/accounts/migrations/__init__.py b/intent/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/intent/accounts/models.py b/intent/accounts/models.py new file mode 100644 index 0000000..dcb3983 --- /dev/null +++ b/intent/accounts/models.py @@ -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) diff --git a/intent/core/__init__.py b/intent/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/intent/core/apps.py b/intent/core/apps.py new file mode 100644 index 0000000..f16645a --- /dev/null +++ b/intent/core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + name = "intent.core" + label = "core" diff --git a/intent/core/middleware.py b/intent/core/middleware.py new file mode 100644 index 0000000..a9ee6c1 --- /dev/null +++ b/intent/core/middleware.py @@ -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) diff --git a/intent/core/models.py b/intent/core/models.py new file mode 100644 index 0000000..80c6f60 --- /dev/null +++ b/intent/core/models.py @@ -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 diff --git a/intent/core/views.py b/intent/core/views.py new file mode 100644 index 0000000..26cc641 --- /dev/null +++ b/intent/core/views.py @@ -0,0 +1,5 @@ +from django.shortcuts import render + + +def home(request): + return render(request, "core/home.html") diff --git a/litestream.yml b/litestream.yml new file mode 100644 index 0000000..fd4a3b4 --- /dev/null +++ b/litestream.yml @@ -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} diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..d98fd81 --- /dev/null +++ b/manage.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4f819c9 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/static/css/intent.css b/static/css/intent.css new file mode 100644 index 0000000..630ebd3 --- /dev/null +++ b/static/css/intent.css @@ -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; +} diff --git a/static/js/theme.js b/static/js/theme.js new file mode 100644 index 0000000..1bcffbb --- /dev/null +++ b/static/js/theme.js @@ -0,0 +1,40 @@ +// Three-way theme choice (system, light, dark), stored per browser. +// Loaded in
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; + }); +})(); diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..3fb544b --- /dev/null +++ b/templates/base.html @@ -0,0 +1,34 @@ +{% load static %} + + + + + +Track the why behind features, across every implementation.
+{% endblock %} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..4cdb72f --- /dev/null +++ b/tests/test_core.py @@ -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 '' in html + assert '' in html + assert 'href="#main"' in html + assert '