arthur/server/settings.py
Olive Vaughn 6a53adb5e0 Projects live at URLs, have owners, and are edited together live
A project is only ever at /p/<id>/<slug>; / is the index of the projects
you own or edit. Every project has an owner, who can name editors;
anyone with the link can view. Every edit saves itself, one request in
flight at a time, as a patch of the leaves that changed, and a websocket
(channels + daphne) carries presence and each committed write to
everyone else in the project. The first write to a leaf wins, and the
loser is told.

Undo is per person: a step undoes only if the leaves it touched still
hold what it left, so it never takes a collaborator's work with it.
Named snapshots replace saving, and restore as an ordinary write.

An empty symbol now survives the leaf round trip with `:nodes {}`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:04:03 -04:00

141 lines
5.5 KiB
Python

"""Settings for arthur's backend.
It serves three things and they are three different kinds of thing, which is most
of what there is to know about this file:
THE PAGE. One template, which replaced `frontend/public/index.html` at step 9.
It pulls the shadow-cljs bundle out of staticfiles, so `manage.py runserver` and
`shadow-cljs watch app` are the whole dev loop with nothing copying files
between them.
THE DOCUMENT (tier 1). Small, authored, in the database.
THE BLOBS (tiers 2 and 3). Large, immutable, content-addressed, on disk under
`var/blobs`. Never in the database, and never in a migration.
"""
from pathlib import Path
import os
BASE_DIR = Path(__file__).resolve().parent.parent
# Dev default. The deployment that needs a real one will set it, and until there
# is a deployment, a checked-in placeholder that says so beats a checked-in secret
# that does not.
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-only-not-a-secret-arthur")
DEBUG = os.environ.get("DJANGO_DEBUG", "1").lower() in {"1", "true", "yes"}
ALLOWED_HOSTS = os.environ.get(
"DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,[::1]"
).split(",")
CSRF_TRUSTED_ORIGINS = [
origin for origin in os.environ.get("DJANGO_CSRF_TRUSTED", "").split(",")
if origin
]
INSTALLED_APPS = [
# First, so `runserver` is daphne's and serves the websocket too.
"daphne",
"channels",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"clips",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
]
# Process-local, like the consumer's ROOMS: one worker. docs/architecture.md
# names the move — Redis — for the day there is a second.
CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}}
ROOT_URLCONF = "server.urls"
WSGI_APPLICATION = "server.wsgi.application"
ASGI_APPLICATION = "server.asgi.application"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
# WAL and a real busy timeout, because a save is a BURST of writes: one analysis,
# then a block per dense channel, then the leaves. Under the default rollback
# journal and a 5-second timeout, the block uploads of one save fail with
# "database is locked" — which surfaces in the page as a 500 with nothing wrong,
# and in the browser suite as a document that will not save. WAL lets readers and
# one writer proceed at once, and the timeout makes the writers queue instead of
# giving up. The client serialises its uploads as well (see events/project), so
# this is the belt to that braces.
#
# The ceiling this has is the one docs/architecture.md already names for tl's
# process-local ROOMS: one writer. It is a single-worker arrangement, and moving
# off it is a Postgres URL rather than a change to anything above this line.
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.environ.get("DJANGO_DB_PATH", str(BASE_DIR / "db.sqlite3")),
"OPTIONS": {
"timeout": 20,
"init_command": "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;",
},
}
}
AUTH_PASSWORD_VALIDATORS = []
LANGUAGE_CODE = "en-gb"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# --- static -----------------------------------------------------------------
#
# Two roots, and the second is the interesting one. `static/` is where
# shadow-cljs writes the bundle (`:output-dir "../static/arthur/js"`), so the
# build lands straight in the staticfiles tree. The vendored MediaPipe is served
# under the prefix `mediapipe/` from where it already lives in `frontend/public/`
# — 26MB of wasm and model that has no business being copied into a second place
# in the tree, and that `collectstatic` picks up from there.
STATIC_URL = "/static/"
STATICFILES_DIRS = [
BASE_DIR / "static",
("mediapipe", BASE_DIR / "frontend" / "public" / "mediapipe"),
]
STATIC_ROOT = BASE_DIR / "var" / "static"
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedStaticFilesStorage"},
}
# --- blobs ------------------------------------------------------------------
#
# Tiers 2 and 3 live here, named by the sha256 of their own bytes, fanned out two
# levels so no directory holds a hundred thousand entries. Deliberately NOT under
# `static/`: a blob is served by a view that can set immutable cache headers and
# refuse a path that is not a hash, and `collectstatic` has no business walking
# gigabytes of frames.
BLOB_ROOT = Path(os.environ.get("DJANGO_BLOB_ROOT", str(BASE_DIR / "var" / "blobs")))
# The port the browser suite expects by default, and not 8777, which is still the
# old JS tool's under `serve.py`. Both are meant to run side by side.
DEV_PORT = 8778