Imported from epicserve/django-base-site (
AGENTS.md). Install upstream withnpx skills add epicserve/django-base-site. Copyright stays with the author.
AGENTS.md
This file provides guidance to coding agents when working with code in this repository.
Project Overview
Django Base Site is an opinionated Django starter template with a production-ready foundation. The stack is Django 6 + django-ninja + django-allauth (headless mode, with MFA + WebAuthn passkeys) on the backend, and a Vue 3 SPA with Tailwind v4 on the frontend. Multi-tenant scaffolding (organizations, teams, invites), a notifications system (in-app + email, per-user category prefs, generic-target cleanup, retention purge), and an opt-in Stripe billing app (Checkout + Customer Portal, settings-declared plans + features, per-seat pricing, trials, coupons) are built in. Celery + Redis for background tasks, MinIO for S3-compatible local media storage, gunicorn in production.
Architecture
- Apps: Under
apps/—accounts/(custom user with timezone + avatar fields),base/(utilities, ninja error handlers, storage backends, the SPA shell view, theqr_svgview, etc.),organizations/(Organization, OrganizationMember, OrganizationInvite + the public invite ninja API),teams/(Team model + ninja API),notifications/(Notification + NotificationPreference, ninja API at/api/notifications/,notify()producer helper, retention purge via celery beat, GenericForeignKey post_delete cleanup driven bysettings.NOTIFICATIONS_TARGET_MODELS, per-user category prefs declared insettings.NOTIFICATIONS_CATEGORIES),billing/(opt-in Stripe subscriptions tied to Organizations — see Billing section below). - Billing app (
apps/billing/, opt-in viaBILLING_ENABLED): models areBillingCustomer+Subscription+WebhookEvent. Plans + features are declared insettings.BILLING_PLANS/settings.BILLING_FEATURESand loaded intoapps/billing/plans.pyandapps/billing/features.pydataclasses (mirrors the notifications-categories pattern). New subscriptions go through Stripe Checkout (full-page redirect); upgrades/cancels/payment-methods/invoices go through the Stripe Customer Portal. The webhook lives at/webhooks/stripe/(registered inconfig/urls.pyoutside the ninja API because it uses HMAC, not session+CSRF) and dedupes Stripe retries viaWebhookEvent. Feature gating:apps.billing.access.org_has_feature(org, key)and therequires_featuredecorator returnTrue/pass through whenBILLING_ENABLED=Falseso the starter template runs out of the box without Stripe credentials. Per-seat sync runs as atransaction.on_commithook fromOrganizationMemberpost_save/post_delete. Trial reminders + drift recovery run as celery beat tasks. - Settings:
config/settings/—_base.py(main, env-driven via epicenv),__init__.py(re-exports_base),test_runner.py(overrides for pytest),e2e.py(overrides for Playwright with pre-built Vite assets). - API: A single
NinjaAPIinstance inconfig/api.pymounted at/api/. Routers come from each app'sapi.py(apps.base.api,apps.accounts.api,apps.organizations.api,apps.teams.api,apps.notifications.api). - URLs (
config/urls.py): A re_path catch-all serves the Vue SPA shell for every non-API path./_allauth/mounts allauth's headless API,/hijack/mounts django-hijack,/admin/is the Django admin,/api/is the ninja API. A_public_not_foundshim before the catch-all keeps stale/public/static/*chunks from being answered with HTML. - Frontend: Vue 3 SPA in
frontend/(wassrc/pre-conversion).frontend/js/app.jsmountsApp.vue,frontend/js/router.jsdefines all SPA routes (lazy-loaded).frontend/js/stores/app.jsis the reactive app store;appStore.fetchContext()hits/api/app-context/to populate user, org, organizations, version, etc.frontend/css/app.cssis Tailwind v4 with Fraunces / IBM Plex Sans / JetBrains Mono via Google Fonts. Built with bun + Vite. - Docker:
compose.ymlwith healthchecks on every service:db(postgres 17),redis(7),mailpit,minio,web,worker(celery),frontend(bun running Vite). The web container runsmigrateandensure_s3_bucketon startup. Multi-stage production image atconfig/docker/Dockerfile.web(python-requirements → base → dev / js_assets → prod with gunicorn). - Static / media: WhiteNoise serves Vite-hashed assets in production with
Cache-Control: max-age=31536000, immutable(regex defined in settings). Media uploads useapps/base/storage.py:S3MediaStoragewhich handles the Docker-internal vs. browser endpoint URL split for MinIO.
Development Commands
Use Just for all development tasks. Common ones:
Setup & Management:
just init- First-time setup: brings services up, runsjust create_superuser, then attaches — use afterscripts/start_new_projector after wiping the DB. Usejust startfor every subsequent boot.just start-docker compose upjust start_with_debugpy- same with debugpy listening on:5678just stop- Stop all servicesjust build- Rebuild Docker images + clear node_modules + collectstaticjust build_frontend-bun run build+ collectstaticjust clean- Remove caches, coverage, distjust create_env- Generate.envfrom the schema in.env.tomljust create_superuser- Idempotentepicenv create-superuser(edit the recipe in the top-leveljustfileto pipe credentials from a secrets manager)
Code Quality:
just format- Format Python (ruff), JS (oxfmt + oxlint), HTML (djlint), justfilejust lint- Run all linters + ty type check + check for missing migrationsjust pre_commit- format + lint + test
Testing:
just test- pytest (Django + ninja API tests)just test_with_coverage- pytest --cov, opens HTML reportjust test_e2e [args]-bun run buildthen pytest e2e/ with--ds=config.settings.e2e. Excluded fromjust testvia--ignore=e2einpyproject.toml.
Database:
just db_dump- pg_dump to~/Downloads/just db_restore [dump_file]- Restore from latest or named dump
Dependencies:
just upgrade_python_packages-uv sync --all-packages --all-extrasjust upgrade_node_packages-bun update
Documentation:
- Documentation is built with Zensical (successor to MkDocs + Material).
just docs- Serve docs locally at http://localhost:4000just docs-build- Build the static site (outputs todocs_site/)just docs-lint- Check links in the documentation- Config:
zensical.toml(at project root) - The old
docker compose --profile docsservice has been removed.
Testing
- pytest with pytest-django.
- Test settings in
config.settings.test_runner; e2e settings inconfig.settings.e2e. - Model Bakery for fixtures.
- Django Test Plus for additional helpers.
- pyotp for the TOTP / MFA tests under
apps/accounts/tests/test_mfa_flows.py. - Playwright e2e tests under
e2e/(auth flow, invite flow);--ignore=e2eis onpyproject.toml[tool.pytest.ini_options]so the unit suite stays fast. - Coverage configuration in
config/coverage.ini.
Code Standards
- Python: Ruff for formatting + linting (replaces Black/isort). Ty for type checking. Django conventions throughout. Bandit (S) ruleset enabled in Ruff.
- Ninja:
[tool.ruff.lint.flake8-bugbear] extend-immutable-callsincludesninja.Query/File/Form/Body/Pathso default-arg-with-call patterns don't trip B008. - JavaScript / Vue: Oxlint (
.oxlintrc.json) for linting + Oxfmt (.oxfmtrc.json, Prettier-compatible) for formatting. Both run viabun run lint-js/format-js. Vue SFC<script>blocks are linted;<template>blocks are formatted but not linted. 120-char line length. - HTML / Django templates: djLint for formatting and linting.
- CSS: Tailwind v4 utilities (no separate Sass/Stylelint pipeline anymore — both were dropped during the SPA conversion).
- Line Length: 120 characters for Python and HTML.
Debugging
The project supports remote debugging with VS Code, PyCharm, LazyVim/Neovim, or any DAP-compatible editor.
Quick Start:
- Start with debugging:
just start_with_debugpy - Wait for "Debugger listening on 0.0.0.0:5678"
- Attach your debugger
Important: Auto-reload is disabled when debugging. Manually restart the server after code changes; use just start for normal development with auto-reload.
VS Code:
- Run:
just start_with_debugpy - Press F5 or select "Django: Attach Debugger" from the debug dropdown
- Set breakpoints and debug your code
PyCharm:
- Configure Docker Compose Python interpreter (Settings → Python Interpreter)
- Create Django Server run configuration
- Click Debug — PyCharm handles everything automatically
- See docs/debugging.md for detailed setup
LazyVim/Neovim:
- Configure nvim-dap to connect to
localhost:5678 - The debugger uses the standard Debug Adapter Protocol (DAP)
- See docs/debugging.md
Notes:
- Debugger listens on port 5678
- Use
just stopthenjust startto switch back to normal mode - PyCharm uses native Docker Compose debugging (doesn't require debugpy)
Environment Configuration
Uses .env for local development. Schema is defined in .env.toml under [variables]. Generate a new .env with just create_env (or uvx epicenv create).
Key variables:
DEBUG=onfor developmentSECRET_KEY— auto-generated by epicenv'surl_safe_passwordinitializerDATABASE_URL— Postgres connection stringSITE_DOMAIN— defaults tolocalhost:8000. Uselocalhost, not127.0.0.1— WebAuthn / passkey enrollment rejects bare IPs as Relying Party IDs.ALLOWED_HOSTS— defaults tolocalhost,127.0.0.1INTERNAL_IPS— for Django Debug ToolbarUSE_DEBUGPY=true— enable remote debuggingMEDIA_S3_*— MinIO / S3 credentials.MEDIA_S3_ENDPOINT_URLis the Docker-internal hostname (http://minio:9000);MEDIA_S3_URL_ENDPOINT_URLis the browser-facing one (http://localhost:9000). The split is handled byapps.base.storage.S3MediaStorage.ACCOUNT_SIGNUP_OPEN— bool, gates new registrations.DJANGO_SUPERUSER_USERNAME/_EMAIL/_PASSWORD— optional; consumed byjust create_superuser(whichjust initruns on first boot) via the idempotentepicenv create-superuser. Leave blank to skip, or edit thejust create_superuserrecipe in the top-leveljustfileto pipe credentials from a secrets manager (1Password, Vault, etc.) instead of putting them in.env.
SPA Auth Flow
HEADLESS_ONLY = True, so allauth never renders templates — it returns JSON via /_allauth/browser/v1/.... The Vue SPA at frontend/js/accounts/views/* drives the entire flow:
- Sign-in:
LoginView.vuePOSTs to/_allauth/browser/v1/auth/login. Honors?next=(skipping/accounts/*redirects). - Sign-up:
SignupView.vuePOSTs to.../auth/signup. Includes a hiddentimezonefield auto-populated byIntl.DateTimeFormat. After signup, if email verification is optional and the user is auto-logged-in, honors?next=. - Email verification: link in the email goes to
/accounts/confirm-email/{key}(Vue route viaHEADLESS_FRONTEND_URLS). - Password reset: 3-step flow under
frontend/js/accounts/views/PasswordReset*View.vue. - MFA: TOTP, recovery codes, WebAuthn passkeys. The TOTP enrollment QR is rendered locally at
/qr/?data=<otpauth-url>(login_required) using theqrcodepackage — no third-party image service. - Org invite: email link points to
/organizations/invite/<key>/accept/, served bySPAViewvia a re_path that keeps theaccept_inviteURL name forreverse(). The Vue page (frontend/js/views/AcceptInviteView.vue) hits/api/invite-by-key/<key>/for the lookup; accept and decline endpoints live on the same prefix.
Multi-Tenant Scaffolding
Organization(name, slug, billing_email),OrganizationMember(withis_owner+is_primaryflags),OrganizationInvite(key-based, 7-day expiration).OrganizationMiddlewarelazy-loadsrequest.orgfrom the session viaapps.organizations.session. The session caches a minimal serialized org dict; the full model instance is only fetched whenorg.instanceis accessed.apps/organizations/signals.pyswitches to the user's primary org on login / hijack-start / hijack-end.- The org switcher in the AppLayout user menu, the OrgSettingsLayout (General / Members / Teams tabs), and the OrgCreateView/OrgSwitchView pages are SPA-driven; the only server-rendered org URL is the catch-all that gives the SPA the accept-invite path.
Key Dependencies
- Backend: Django 6, django-allauth[mfa] (with fido2 for WebAuthn), django-ninja, django-hijack, Pillow, Celery, Redis, PostgreSQL 17, gunicorn, WhiteNoise, django-storages + boto3, django-ses, django-alive, django-maintenance-mode.
- Frontend: Vue 3, Vue Router 5, Tailwind v4, Vite 8, bun, @heroicons/vue, vue-advanced-cropper, reka-ui (modal/toast primitives).
- Development: Docker, pytest, pytest-playwright, pyotp, Ruff, Ty, Oxlint, Oxfmt, djLint, model-bakery.