This directory contains the Django backend for PotterDoc.
PotterDoc now runs as a user-scoped application with session authentication.
- Auth is session/cookie-based (Django + DRF
SessionAuthentication) for the web client. - External agents (MCP servers, ChatGPT custom actions) authenticate with a long-lived
AgentTokeninstead:Authorization: Bearer pdagent_<token>, handled byAgentTokenAuthentication(api/auth/agent_auth.py). Tokens are created/listed/revoked viaGET|POST /api/auth/agent-tokens/andDELETE /api/auth/agent-tokens/<id>/— session-authenticated only, so a token cannot manage other tokens. The web UI's Settings dialog for this isDeveloperTokensDialog.tsx(see web/README.md). Agent tokens grant standard user permissions only, never staff/admin. - The web client fetches a CSRF cookie from
GET /api/auth/csrf/before login/logout/register writes. - Auth endpoints:
POST /api/auth/login/(email + password)POST /api/auth/logout/GET /api/auth/me/POST /api/auth/register/(backend supported; see UI note below)POST /api/auth/google/(Google OAuth 2.0)
- Workflow/data endpoints (
/api/pieces/*,/api/globals/*) require authentication.
PotterDoc supports Google Sign-In using OAuth 2.0 with OpenID Connect. The flow works as follows:
- Frontend: The web app uses
@react-oauth/googleto display a Google Sign-In button whenGOOGLE_OAUTH_CLIENT_IDis configured. - Google Authentication: User clicks the button, Google handles authentication and returns a JWT credential.
- Backend Verification: The frontend sends the JWT to
POST /api/auth/google/, where Django verifies the token with Google's servers usinggoogle-auth. - User Creation/Login:
- If the Google subject ID exists in
UserProfile.openid_subject, the existing user is logged in. - If not, the system looks for an existing user with the same email address (graceful migration from email/password accounts).
- If no user exists, a new account is created with an unusable password (Google-only account).
- If the Google subject ID exists in
- Profile Sync: User profile information (name, picture) is updated from Google on each login.
Environment Variables Required:
GOOGLE_OAUTH_CLIENT_ID: Your Google OAuth client ID from Google Cloud Console
Per-user data isolation rules:
- Every user-owned domain object (
Piece,PieceState,Location,ClayBody,GlazeType,GlazeMethod) has auserforeign key. - List endpoints only return objects for
request.user. - Detail/update endpoints fetch objects from a user-filtered queryset. If another user's ID is requested, the API returns
404(not403) to avoid leaking object existence. - Global reference entries are user-scoped; names are unique per user (for example, two users can both have a
Locationnamed "Kiln A" without colliding).
Endpoint logic now lives in feature subpackages so each concern can keep its own helpers, tests, and compatibility shims:
api/auth/— auth/login/logout/export/account helpersapi/piece/— piece list/detail/state/image helpersapi/global_entries/— shared globals list/create/favorite logicapi/uploads/— direct-to-R2 presigned upload endpointapi/dev/— local bootstrap helpers
Top-level api/*.py modules remain thin compatibility wrappers for stable URL registrations and older imports. When a helper module exposes reusable logic, its public functions should be documented, tested directly, and traced so the module boundary stays observable.
The backend also exposes POST /api/telemetry/traces/, which accepts browser
OTLP/HTTP trace batches and proxies them to the local collector. The frontend
uses this path for same-origin trace export so the browser never needs direct
Grafana credentials.
POST /graphql/ is a Strawberry GraphQL endpoint (api/graphql/schema.py) and is where mutation logic (creating/transitioning pieces, editing state fields, uploading/cropping/moving images, managing globals and favorites) actually lives — see api/graphql/mutations.py. The REST endpoints under /api/pieces/* and /api/globals/* are generated compatibility wrappers: api/graphql/rest_bridge.py declares each REST route as a RestRoute (method, GraphQL operation string, request→variables mapping) and make_rest_view() turns it into a DRF view that calls schema.execute_sync() internally. When adding a new write, implement it as a mutation first; only add a RestRoute if a REST-shaped URL is still needed (OpenAPI-generated frontend types, or the LLM-facing schema in api/llm_schema.py).
CSRF is enforced manually in api/graphql/views.py for session-authenticated mutation requests (Bearer-token requests are exempt, since the token is the credential); read-only queries are exempt entirely. GraphiQL is served at /graphql/ only when DEBUG=True.
Glaze uses YAML-driven configuration to minimize boilerplate and ensure consistency between backend validation and frontend UI.
Drives the UserPreferencesSerializer and the frontend settings dialog.
- Defines sections, fields, types (
string,boolean,field-list), and storage locations (UserProfileorUserProfile.preferences). - Backend logic in
api/preferences.pygenerates serializers dynamically at module load. - See the Frontend Client (
web/) for UI implementation details.
Drives small, dismissible tutorial tips.
- Defines preference keys, inlay labels, and declarative DOM attachment rules (CSS selectors).
- Automatically injected into user preferences for persistence.
- Frontend
TutorialManagerhandles dynamic attachment without manual JSX.
The API Bazel test targets in api/BUILD.bazel set
DJANGO_SETTINGS_MODULE=backend.test_settings through the shared _TEST_ENV
for the default test harness. That keeps the suite on the self-contained test
settings module instead of importing backend.settings for every run.
This buys us two things: smaller test dependencies, and better Bazel cache behavior because the default suite no longer inherits production-only settings branches that can change unrelated test outcomes. In practice, that means the API tests start faster, stay easier to reason about, and only depend on the settings surface the tests actually need.
When changing large download endpoints, run the app locally with gz_start,
trigger the same download three times in the browser, and watch the backend RSS:
BACKEND_PID=$(pgrep -f "uvicorn.*$(cat .dev-pids/backend.port)")
watch -n 1 "ps -o pid,rss,vsz,cmd -p ${BACKEND_PID}"RSS may stay at a high-water mark after the first run, but repeated same-size
downloads should plateau rather than ratchet upward. For ASGI production parity,
repeat the check against Docker/staging and watch the Gunicorn/Uvicorn worker
RSS; large StreamingHttpResponse bodies should use async iterators.
| Command | Description |
|---|---|
gz_manage <cmd> [args…] |
Run any manage.py subcommand. |
gz_migrate |
manage.py migrate |
gz_makemigrations |
manage.py makemigrations |
gz_shell |
Django interactive shell |
gz_dbshell |
Raw database shell (SQLite) |
gz_showmigrations |
manage.py showmigrations |
gz_dump_public_library |
manage.py dump_public_library |
gz_load_public_library |
manage.py load_public_library |
| Command | Description |
|---|---|
gz_gentypes |
Regenerate web/src/util/generated-types.ts from the live OpenAPI schema. Starts the backend temporarily if it is not already running. |
The field DSL supports read-only calculated fields using a recursive, strictly typed AST. These are evaluated on the backend and displayed in the frontend.
fields:
volume_shrinkage:
label: Volume Shrinkage
decimals: 1
display_as: percent
compute:
op: difference
args:
- constant: 1
- op: ratio
args:
- op: product
args:
- { field: glaze_fired.length_in, return_type: number }
- { field: glaze_fired.width_in, return_type: number }
- { field: glaze_fired.height_in, return_type: number }
- op: product
args:
- {
field: submitted_to_bisque_fire.length_in,
return_type: number,
}
- {
field: submitted_to_bisque_fire.width_in,
return_type: number,
}
- {
field: submitted_to_bisque_fire.height_in,
return_type: number,
}Supported operations: sum, product, difference (2 args), ratio (2 args).
Leaf nodes: field (e.g. { field: state_id.field_name, return_type: number }) or constant.
Display options: display_as: percent (multiplies by 100 and adds %).
Some global types (currently Clay Bodies and Glaze Types) support a shared public library managed by site administrators. Public entries are visible to all authenticated users as read-only reference data; each user can also have their own private copies with unique names.
- Start the app with
gz_start. - Go to
http://localhost:8080/admin/and sign in with a Django superuser account.- To create a superuser:
gz_manage createsuperuser(orpython manage.py createsuperuser).
- To create a superuser:
On the admin homepage, public library models appear in a dedicated Public Libraries section, separate from the general Api section. This section lists only public objects (those with no owner). Users' private copies are not shown here and remain accessible only via the shell/ORM.
- Click a model name (e.g. "Clay Bodies") in the Public Libraries section.
- Click "Add Clay Body" (or click an existing row to edit it).
- Fill in the fields. The
Userfield is hidden — public objects are always unowned. - For fields marked as an image in
workflow.yml(e.g. the clay body or glaze type tile image):- If R2 is configured, an Upload Image button appears next to the URL field. Clicking it opens a file picker, uploads the file directly to R2 via a presigned URL, writes the resulting public URL back into the field, and shows a thumbnail preview.
- If R2 is not configured, you can paste a URL directly into the text field.
- Click Save.
- Public name must be unique — you cannot save a public entry with the same name as another existing public entry.
- Private entries may share a public name — users can have their own private entry with the same name as a public entry. When both exist, the picker displays the public entry with a
(public)suffix to distinguish the two.
Admin uploads use the same presigned direct-to-R2 flow as the regular user UI (POST /api/uploads/r2/presigned-url/). Set these env vars before starting Django (all five are required together):
export R2_ACCOUNT_ID=<cloudflare-account-id>
export R2_ACCESS_KEY_ID=<r2-token-key-id>
export R2_SECRET_ACCESS_KEY=<r2-token-secret>
export R2_BUCKET_NAME=<bucket-name>
export R2_PUBLIC_URL=<public-cdn-base-url> # e.g. https://media.potterdoc.comThe upload button is automatically hidden when these are not set; the plain URL field is always available as a fallback.
Once you have authored public library entries via the admin, you can export them to a versioned fixture file and deploy them to other environments (staging, production) without SSH access.
1. Export from your dev environment:
gz_dump_public_libraryThis writes fixtures/public_library.json — a portable snapshot of every public object across all public: true globals (currently Clay Bodies and Glaze Types). The pk and user fields are excluded so the file works across databases.
2. Commit and open a PR:
git add fixtures/public_library.json
git commit -m "Update public library"
# open a PR as usual3. Automatic deployment on merge:
When the PR merges, CI builds and pushes a new Docker image. On the next docker compose up -d, deploy_init applies migrations, loads the public library, and clears stuck tasks before the app container starts:
python manage.py migrate --no-input &&
python manage.py load_public_library --skip-if-missing &&
python manage.py clear_stuck_tasks --hours 1load_public_library does an idempotent update_or_create for each record — running it multiple times is safe. The --skip-if-missing flag lets fresh deployments start cleanly before any fixture has been committed yet.
Optional path overrides:
# Load from a non-default path:
gz_load_public_library --fixture path/to/custom.json
# Export to a non-default path:
gz_dump_public_library --output path/to/custom.json
# Inspect the export without writing a file:
gz_dump_public_library --output -