iru-setup-python-django

Scaffold a new Django 6.1 project with uv (`uv init --bare` + `uv add`, committed `uv.lock`, `.python-version`) and `django-admin startproject config .`, or bring an existing Django project up to this

SKILL.md

GitHub

Setup Python Django

Scaffold a Django project with uv and startproject, then layer on this catalog's standard toolchain and a small, real, tested project skeleton: split settings fed by the environment, a custom user model before the first migration, health probes, one sample app with a service layer and an API, fast and integration test tiers, strict typing, a production container image, Sphinx API docs, and Sonar configuration. Every template in reference/ was rendered into throwaway projects and run against the real tooling (see Verification notes); the gotchas called out inline are observed behavior.

The generated code is the first exemplar of the conventions iru-django-code-one-task enforces (apps under apps/ with AppConfig.name = "apps.<x>", views/API → services.py → models, @transaction.atomic services that turn IntegrityError into domain errors, explicit serializer/schema fields, factories over fixtures, migrations generated by makemigrations), and every command in Step 13 is the one iru-setup-python-github-workflows and the iru-python-test/-coverage/-code-quality/-docstrings gates run.

Reference files — read only what this run needs

Read When
reference/project-templates.md Always. pyproject.toml header + tool config, config/ (outside settings/), manage.py, gunicorn.conf.py, apps.core, apps.accounts.
reference/settings-templates.md Always. config/settings/{__init__,base,local,test,production}.py.
reference/sample-app-and-tests.md Always for a new project; for an existing one only when the user wants the sample. The catalog app, tests/ (conftest, factories, fast tier, integration tier).
reference/api-drf.md / reference/api-ninja.md Only the one matching api (nothing for api: none).
reference/docs-docker-and-sonar.md Always (Sphinx sources, Dockerfile, .dockerignore, compose.yaml, .env.example); its Sonar section only when sonar ≠ none.
reference/ui-tests.md Always. .ui-quality.yml and the browser UI tier (tests/ui/: helper, fixtures, the AdminLoginScreen test).

Template conventions

The templates are shared by every api/database combination:

  • A line ending in # [api: drf] (or ninja/none) or # [database: postgresql] (or sqlite) is kept only for that choice, with the tag removed; otherwise the line is dropped.
  • A line that is only such a tag (e.g. # [api: drf]) opens a block kept only for that choice; # [end] closes it. Drop both marker lines.
  • Whole files are per-choice where the heading says so: apps/catalog/api/ (package) + tests/catalog/test_api_drf.py only for drf; config/api.py, apps/catalog/api.py (module), apps/catalog/schemas.py, tests/catalog/test_api_ninja.py only for ninja; tests/integration/ only for database: postgresql.
  • Placeholders: <name> (distribution name), <package> (snake_case identifier — default PostgreSQL database/user name), <description>, <license>, <developer-name>, <developer-email>, <organization-url>, <python-version>, <uv-version>, and the Sonar keys. Nothing else in angle brackets is a placeholder.

After rendering, no # [ tag and no <placeholder> may remain (grep -rn '# \[\(api\|database\|end\)' . must be empty outside .venv).

Step 0 — Resolve inputs

Invoked stand-alone (/iru-setup-python-django) or by iru-setup-python-repository, which passes already-resolved answers through args as key: value lines, one per line, e.g.:

name: acme-books
package: acme_books
description: Book catalog service.
license: BSD-3-Clause
developer-name: Jane Doe
developer-email: jane@example.com
organization-url: https://github.com/acme
python-version: 3.13
database: postgresql
api: drf
open-source: yes
visibility: public
ci: yes
distribution: internal
sonar: cloud
sonar-organization: acme-github
sonar-project-key: acme_acme-books
sonar-host-url: https://sonarcloud.io
accessibility: enforce
mode: new
on-existing: ask

Parse any such lines now; every key found is resolved — never ask it again. If args is absent or not in this format, treat everything as unset.

Recognized keys:

  • Identity: name (the [project].name, lowercase kebab-case; also the image and compose project name), package (snake_case identifier; Django code lives in config/ and apps/, so it only names the default PostgreSQL database/user — default: name with - → _), description, license (SPDX id — map "MIT License" → MIT, "BSD 3-Clause" → BSD-3-Clause, "Apache License 2.0" → Apache-2.0; "No license" → no license key plus the Private :: Do Not Upload classifier), developer-name, developer-email, organization-url.
  • Shared pipeline vocabulary: python-version (CPython minor the project runs on, default 3.13), open-source (yes/no), visibility (public/private/internal), ci (yes/no — only consulted by the Sonar question), distribution (none/internal/store — only reported; the Dockerfile is written either way and the workflows decide whether to push it), sonar (cloud/self-hosted/none) plus, only when cloud/self-hosted, sonar-organization/sonar-project-key/sonar-host-url, accessibility (enforce/report/off/none, default enforce — the automated accessibility level of the browser UI tests, written to .ui-quality.yml; asked only in a stand-alone run, Step 2), mode (new/existing), on-existing (ask/keep/update/replace, default ask, for scripted runs).
  • This skill only: database (postgresql/sqlite, default postgresql; none is invalid — Django's auth, sessions and admin need a database: warn and ask), api (drf/ninja/none, default drf).

Ignore any other key (ci-tests, pages, publish, integration-branch, stable-branch, orm, … may arrive from the orchestrator). mode: existing marks an update run but never means "update silently": Step 1 still warns and asks per pre-existing artifact unless on-existing says otherwise.

Accessibility level none and assistive-technology templates. accessibility takes enforce, report, off or none (UI contract §8; the stand-alone question gains a fourth option, None — opt out entirely, e.g. a mandated non-compliant design). With none: write accessibility: none to .ui-quality.yml, do not install or configure the accessibility test tooling (axe-core in the browser UI tests), and omit the accessibility-specific attributes from the generated templates. With enforce, report or off: include the assistive-technology attributes from the contract's §8 platform subsections in every generated template — semantic template landmarks, labelled form controls, alt on images, relative font sizes, prefers-reduced-motion guards — and, for enforce/report only, the automated checks. In mode: existing, a prior .ui-quality.yml follows this skill's existing Update / Keep / Replace prompt and its accessibility value is never silently flipped (Update sets it only when args supplied it).

Step 1 — Survey the repository

  • Detect a Django project: a root manage.py and django among pyproject.toml [project].dependencies (or [tool.poetry.dependencies], requirements*.txt, Pipfile). Read the Django version from uv.lock/poetry.lock or uv run python -m django --version. If mode wasn't supplied, derive it: project present ⇒ existing, absent ⇒ new.
  • A Python project that is not Django (a FastAPI service, a library): stop — this skill only scaffolds/updates Django projects; suggest iru-setup-python-repository to pick the right flavor. Never run startproject over another project's manifest.
  • Existing project shape: settings as a package (config/settings/) or a single settings.py (and which module manage.py/wsgi.py default to), the project package name (config or another), app layout (apps/<x>, root-level apps, a cookiecutter project-slug package), AUTH_USER_MODEL (and whether migrations already exist — see Step 7), the API framework already installed (rest_framework/ninja in INSTALLED_APPS fixes api; tell the user when that disagrees with args), the test runner (pytest-django or manage.py test), the package manager (uv.lock / poetry.lock / Pipfile.lock / requirements*.txt), and the database engine (psycopg or django.db.backends.postgresql ⇒ postgresql).
  • Survey every artifact this skill owns: pyproject.toml + uv.lock + .python-version, the tool sections ([tool.ruff], [tool.mypy] + [tool.django-stubs], [tool.pytest.ini_options], [tool.coverage.*]), manage.py, config/settings/ (or settings.py), config/env.py, config/urls.py, config/asgi.py/wsgi.py, config/api.py (Ninja), apps/core/, apps/accounts/, the sample apps/catalog/, tests/ (conftest.py, factories.py), gunicorn.conf.py, docs/api/conf.py + docs/api/*.rst, Dockerfile, .dockerignore, compose.yaml / docker-compose.yml, .env.example, sonar-project.properties, .ui-quality.yml, and the UI tier (tests/ui/ui_screenshots.py, tests/ui/conftest.py, the ui marker, the Playwright dev dependencies, LocaleMiddleware in the settings).
  • Announce the mode before asking anything else:
    • Nothing owned exists: "Scaffolding the Django project from scratch."
    • Anything owned exists (or mode: existing): "Updating an existing setup — found: <list with paths> (Django <version>, Python <requires-python>, settings <shape>, API <drf/ninja/none>)." Warn that each will be handled individually, then resolve one action per artifact:
      • on-existing is keep/update/replace: apply it to every artifact without asking (still print the warning). Exception: replace never re-scaffolds an existing project, its settings, or its apps — treat those as update and say so.
      • Otherwise: ask with AskUserQuestion, up to 4 artifacts per call (one question each), naming concrete contradictions with this run's choices in the question (e.g. "pyproject.toml has no integration marker — the CI integration job selects it", "settings.py hard-codes SECRET_KEY", "[tool.coverage.xml] writes coverage.xml at the root, not build/coverage/coverage.xml", "a sonar-project.properties exists but you chose sonar: none", "Dockerfile runs migrate in its CMD", ".ui-quality.yml says accessibility: off but you chose enforce", "tests/ui/ has no ui marker registered"):
        • Update (Recommended) — merge: add what's missing and correct what this skill owns (missing dev dependencies and tool sections, the integration and ui markers, coverage paths, a settings split around the existing module, health views, .dockerignore entries, .ui-quality.yml keys — a different accessibility is set to this run's value, a narrowed directions/themes list is kept), keeping everything else. Show the diff; customizations survive, but a customized part this skill owns may be rewritten.
        • Keep as is — leave it untouched and skip the steps that only serve it. It may stay out of date or disagree with this run's choices — Step 14 lists each mismatch.
        • Replace — regenerate from the template; customizations are lost (recoverable only from git history). Not offered for manage.py, the settings, config/urls.py, or an app: they are real code once the project exists — Update or Keep only. For the sample catalog app recommend Keep as is. An existing tests/ui/ui_screenshots.py is the project's own helper (the coding skills extend it): Update merges in missing functions only, never Replace by default.
        • Stop (first batch only) — end without changing anything; report that and end here.
      • Never add the sample catalog app to an existing project unless the user asks — it would be dead code. Ask once: "Add the catalog sample app as a reference?" (default No). The health probes and config/env.py are offered (they're infrastructure, not sample code).
      • Ask about sonar-project.properties only after Step 2 resolves sonar: with sonar: none, Update/Replace mean removing it and Keep leaves it as a reported mismatch.
  • Placement (only when Step 4 will scaffold): the repository root. uv init --bare only writes pyproject.toml, and django-admin startproject config . refuses to overwrite existing files but tolerates other ones (README.md, LICENSE, docs/, .git, .github/ are fine). If the root holds another project's pyproject.toml/manage.py/config/ package, stop and ask whether to scaffold into a subdirectory named <name>.

Step 2 — Collect identity and options

For every key Step 0 resolved, use it. In mode: existing, read identity from pyproject.toml [project] (name, description, license, authors, urls) and confirm it in one line instead of asking. Otherwise ask (plain conversation for free text, AskUserQuestion for bounded choices):

  1. name (default: the repository directory's basename, lowercased, kebab-case), package (derived; confirm only; must be a valid identifier and not django, test, config, apps), description, developer name/email, organization URL (default: https://github.com/<owner> from git remote get-url origin).
  2. license — BSD-3-Clause (Recommended) "Django's own license" / MIT / Apache-2.0 / No license (proprietary). Remind in Step 14 to add a matching LICENSE file if missing (iru-check-license backfills headers).
  3. python-version — default 3.13 ("the version the irurueta docs' examples, CI and Docker use"). Django 6.1 supports 3.12–3.14; never accept a value outside that range. requires-python stays >=3.12 (the floor); python-version is what .python-version, the Dockerfile base image and sonar.python.version use.
  4. database — PostgreSQL (Recommended) "psycopg 3; fast tests on SQLite in memory, an integration tier on a real PostgreSQL via testcontainers (needs Docker), compose.yaml with postgres:18" / SQLite "file database in every environment; no integration tier, no database container".
  5. api — Django REST Framework (Recommended) "DRF 3.18 + drf-spectacular OpenAPI schema under /api/v1/; the irurueta docs' primary choice" / Django Ninja "typed, Pydantic-based endpoints with a built-in OpenAPI schema; async-capable, served on ASGI" / None "no API layer (admin, services and models only)".
  6. sonar — "Configure SonarCloud / SonarQube for this project?". Resolve visibility only now if missing (gh repo view --json visibility -q .visibility, lowercased; else ask Public/Private — internal counts as private). If open-source: yes but private/internal, flag the contradiction and ask which is right. Recommend SonarCloud (cloud) only for a public open-source repository; otherwise say SonarCloud is free only for public open-source projects and recommend None, offering self-hosted second. With ci: no, say no CI workflow would send reports and recommend None. For cloud ask sonar-organization (suggest <owner>-github) and sonar-project-key (suggest <owner>_<repo>), host https://sonarcloud.io; for self-hosted ask the host URL, the organization only if the server uses them, and the project key.
  7. accessibility (stand-alone runs only; an orchestrator always passes it) — "Support accessibility and assistive technologies?" Enforce (Recommended) "axe violations fail the UI tests" / Report only "listed in the test output and the gate summary, never failing" / Off "not run; the accessibility coding defaults still apply". Written to .ui-quality.yml.

Step 3 — Toolchain and versions

  • uv is required: command -v uv and uv --version. Without it, stop and tell the user to install it (https://docs.astral.sh/uv/getting-started/installation/) — never pip install into a system Python. An existing project managed by Poetry/Pipenv/pip keeps its manager: run the equivalent commands (poetry add --group dev …, poetry run …) and say in Step 14 that the CI workflows expect uv (uv.lock), so a migration to uv is recommended.
  • uv python install <python-version> if uv python find <python-version> fails.
  • Docker (docker info) is needed only for the integration tier and the image build — its absence makes those checks unverified, never skipped silently.
  • Versions are resolved live by uv add (latest release within each range below); record what uv.lock ended up with. Ranges: django>=6.1,<6.2 (pin the feature series), djangorestframework>=3.18,<3.19, drf-spectacular>=0.30,<0.31, django-ninja>=1.7,<2, pytest-playwright>=0.10, axe-playwright-python>=0.1.8; everything else takes uv add's default >=<latest> floor. <uv-version> for the Dockerfile is uv self version --short (or the latest astral-sh/uv release) — pin an exact tag, never latest.

Step 4 — Scaffold (new projects only)

Skip for an existing project. From the repository root:

uv init --bare --python <python-version> --name <name> --description "<description>"
uv python pin <python-version>
uv add "django>=6.1,<6.2" "django-stubs-ext>=6.1" whitenoise gunicorn
uv add "psycopg[binary]"                                                  # database: postgresql
uv add "djangorestframework>=3.18,<3.19" "drf-spectacular>=0.30,<0.31"    # api: drf
uv add "django-ninja>=1.7,<2" uvicorn-worker                              # api: ninja
uv add --group dev pytest pytest-django pytest-cov factory-boy ruff mypy "django-stubs[compatible-mypy]"
uv add --group dev "pytest-playwright>=0.10" "axe-playwright-python>=0.1.8"   # browser UI tier (every choice)
uv add --group dev "testcontainers[postgres]"                             # database: postgresql
uv add --group dev djangorestframework-stubs                              # api: drf
uv add --group docs sphinx sphinx-autodoc-typehints
uv run django-admin startproject config .
mkdir -p apps
for app in core accounts catalog; do mkdir -p "apps/$app" && uv run django-admin startapp "$app" "apps/$app"; done
  • uv init --bare writes only pyproject.toml (no README.md, main.py, or .python-version — uv python pin writes the latter, which CI's setup-uv/setup-python can read). uv add creates .venv/ and uv.lock.
  • django-stubs-ext is a runtime dependency: base.py calls django_stubs_ext.monkeypatch() so the generic classes mypy needs (admin.ModelAdmin[Book], UserAdmin[User], QuerySet[Book]) can be subscripted at run time — without it, importing admin.py fails with TypeError: type 'ModelAdmin' is not subscriptable (verified).
  • Then delete what the templates replace or don't use: config/settings.py (→ the settings package), apps/*/tests.py, apps/*/views.py (core's is rewritten), and apps/core/migrations/ (core holds only abstract models). Skip catalog when the sample isn't wanted (existing projects).
  • Ninja note: apps/catalog/api.py is a module; DRF uses an apps/catalog/api/ package — never create both.

Step 5 — pyproject.toml

From reference/project-templates.md:

  • Rewrite the [project] header (keep the dependencies list uv add wrote): version = "0.1.0.dev0" (the PEP 440 development version; release X.Y.Z), requires-python = ">=3.12", license (SPDX), authors, [project.urls] Homepage. Add readme = "README.md" only if README.md exists — uv_build fails the build when the file is missing (verified); iru-setup-readme runs later in the orchestrated chain.
  • [build-system] uv_build with [tool.uv.build-backend] module-name = ["apps", "config"], module-root = "": the project is installed into .venv, which is what lets config/__init__.py read the version with importlib.metadata.version("<name>") (a virtual, un-built project has no metadata), and uv build produces the sdist/wheel the release workflow attaches.
  • Append the tool sections verbatim (api tags resolved): Ruff (line length 88 — Ruff/Black's default, the width the formatter is tuned for; the docs' PEP 8 79 would wrap Django's long dotted settings paths constantly), select incl. DJ, ignore = ["D106", "RUF012"] (Django/DRF configuration class attributes and Meta classes), Google pydocstyle, per-file-ignores for tests/** (S101, D) and **/migrations/**, migrations excluded from ruff format (they are generated; makemigrations output never has to be reformatted); mypy strict with the django-stubs plugin (+ mypy_drf_plugin.main for DRF, pydantic.mypy for Ninja), migrations/build//docs/ excluded, factory_boy overrides; [tool.django-stubs] django_settings_module = "config.settings.test"; pytest (DJANGO_SETTINGS_MODULE = "config.settings.test", addopts = "-ra --strict-markers --strict-config", the integration and ui markers, filterwarnings = ["error::DeprecationWarning"]), a mypy override ignoring axe_playwright_python's missing type information; coverage (branch, source = ["apps", "config"], omit migrations/settings/asgi.py/wsgi.py/manage.py, fail_under = 80 — the catalog's gate; the irurueta docs use 90, offer it if the user wants the stricter floor — and XML at build/coverage/coverage.xml).
  • Existing project (Update): merge section by section; never drop a rule or override a stricter value the project already has (e.g. fail_under = 90 stays); add the integration and ui markers and the coverage XML path; report each change.

Run uv lock && uv sync afterwards.

Step 6 — Settings and entry points

From reference/settings-templates.md and reference/project-templates.md: config/settings/{__init__,base,local, test,production}.py, config/env.py, config/__init__.py (__version__ from the distribution metadata), config/urls.py, config/asgi.py/wsgi.py (default config.settings.production), manage.py (default config.settings.local), gunicorn.conf.py.

  • Environment contract (production; config/env.py raises ImproperlyConfigured when a required one is missing): required DJANGO_SECRET_KEY, DJANGO_ALLOWED_HOSTS (comma-separated), and for PostgreSQL POSTGRES_HOST, POSTGRES_PASSWORD; optional POSTGRES_PORT (5432), POSTGRES_DB/POSTGRES_USER (<package>), DJANGO_SECRET_KEY_FALLBACKS, DJANGO_CSRF_TRUSTED_ORIGINS, DJANGO_HTTPS (default on: SSL redirect, secure cookies, HSTS 1 year + subdomains + preload — check --deploy --fail-level WARNING passes only with all of them; 0 only for plain-HTTP smoke runs), DJANGO_SECURE_PROXY_SSL_HEADER (only behind a proxy that strips the header), DJANGO_SECURE_HSTS_*, DJANGO_CONN_MAX_AGE, DJANGO_EMAIL_* (SMTP MAILERS — Django 6.1's mail.E001 deploy check rejects the console backend), DJANGO_SQLITE_PATH (SQLite), DJANGO_ADMIN_URL, DJANGO_LOG_LEVEL, WEB_CONCURRENCY/GUNICORN_*. local.py uses PostgreSQL when POSTGRES_HOST is set (e.g. uv run --env-file .env python manage.py … against the compose database), else db.sqlite3, and a random key unless DJANGO_SECRET_KEY is set. test.py is fully self-contained (SQLite :memory:, MD5 hasher, InMemoryStorage, dummy cache, locmem mailer, WHITENOISE_AUTOREFRESH = True — without it every test client request warns "No directory at: staticfiles/", verified).
  • LocaleMiddleware sits after SessionMiddleware in base.py: a django_language cookie (or Accept-Language) activates a language and the admin's <html dir> follows LANGUAGE_BIDI, which is how the UI tier renders right-to-left for real. Without a request preference the language stays LANGUAGE_CODE, so API responses and the fast tests are unchanged. Existing project (Update): add it only if it isn't there and the project has no other locale mechanism (i18n_patterns, a custom middleware); otherwise keep the project's and make the helper's apply_direction_and_theme select the language that way (an i18n_patterns prefix wins over the cookie).
  • No STATICFILES_DIRS pointing at a directory that doesn't exist: staticfiles.W004 is a warning and fails check --deploy --fail-level WARNING. Add static/ + the setting together when the project gets assets.
  • Server: api: drf/none → WSGI gthread (DRF is sync-only; no async code ⇒ stay on WSGI); api: ninja → config.asgi:application with uvicorn_worker.UvicornWorker (the maintained worker package, not the deprecated uvicorn.workers). The Gunicorn log line "ASGI 'lifespan' protocol appears unsupported" is expected.
  • Existing project with a single settings.py (Update): don't split it silently — propose the split as a diff (base.py = the current module minus secrets/hosts; the three environment modules from the templates), and repoint manage.py, wsgi.py, asgi.py, pytest and django-stubs at the new modules. If the user keeps the single module, only add env-driven SECRET_KEY/DEBUG/ALLOWED_HOSTS and report the rest as mismatches.

Step 7 — Apps, custom user, first migrations

Write apps/__init__.py, apps/core/ and apps/accounts/ from reference/project-templates.md, and (new projects, or when asked) apps/catalog/ from reference/sample-app-and-tests.md plus the API files from reference/api-drf.md or reference/api-ninja.md. Every apps.py sets name = "apps.<x>" and label = "<x>" (without the name edit, startapp's name = "<x>" raises ImproperlyConfigured: Cannot import '<x>').

Then generate the first migrations — after AUTH_USER_MODEL = "accounts.User" is in base.py, so the custom user is in accounts/0001_initial:

uv run python manage.py makemigrations accounts catalog

Never hand-write or edit a generated migration. Existing project that already has migrations with the default auth.User: don't swap AUTH_USER_MODEL (that is InconsistentMigrationHistory territory and needs a manual data migration) — keep its user model, skip apps.accounts, and report it; the irurueta docs' alternative is a profile model with a OneToOneField.

Step 8 — Tests

From reference/sample-app-and-tests.md (+ the API test file): tests/__init__.py and one package per area (__init__.py in every test directory — test module basenames repeat across them), tests/conftest.py, tests/factories.py, the fast tests, and — database: postgresql — tests/integration/.

  • Fast tier (-m "not integration"): SQLite in memory via config.settings.test; no service, no Docker. This is the tier the 80% coverage gate measures (verified 100%).
  • Integration tier (-m integration, database: postgresql only): when any selected test carries the integration marker, the session-scoped django_db_modify_db_settings override in tests/conftest.py starts postgres:18-alpine with testcontainers (from testcontainers.community.postgres import PostgresContainer — the old testcontainers.postgres path raises a DeprecationWarning, which filterwarnings = error turns into a failure, verified with testcontainers 4.15), mutates settings.DATABASES["default"] in place, then closes and deletes connections["default"] — without that last step the already-created SQLite connection is reused and the "integration" tests silently run on SQLite (verified). pytest-django then creates the test database on the container and applies every migration, so the tier also proves the migrations run on PostgreSQL. A plain uv run pytest (both tiers in one session) runs everything on PostgreSQL.
  • database: sqlite: no tests/integration/, no testcontainers; pytest -m integration then collects nothing and exits with code 5 — callers must treat that as "no integration tier", not a failure.
  • UI tier (-m ui, every database/api) — from reference/ui-tests.md: .ui-quality.yml at the root (accessibility from Step 0/2, directions: [ltr, rtl], themes: [light, dark] — the contract's exact shape), tests/ui/__init__.py, the shared helper tests/ui/ui_screenshots.py (ui_quality(), screenshots_dir(), language_for(), apply_direction_and_theme(), capture_ui_state(), check_accessibility() — the same API iru-django-code-one-task documents, so later UI tests import it unchanged), tests/ui/conftest.py (DJANGO_ALLOW_ASYNC_UNSAFE — Playwright's sync API runs an event loop in the test thread, which would make pytest-django's database setup raise SynchronousOnlyOperation; the direction × theme parametrization from .ui-quality.yml; a fixed 1280×800 viewport, en-US, UTC, reduced motion), and tests/ui/test_admin_login.py (pytest-django live_server + pytest-playwright page; AdminLoginScreen default and error states, each captured to .ui-screenshots/screen/AdminLoginScreen/<state>--<direction>--<theme>.png and checked with axe-core — WCAG 2.x A/AA tags — at the configured level; every scan is appended to .ui-screenshots/a11y-report.jsonl). The conftest deselects ui tests unless the -m expression names ui, so uv run pytest, -m "not integration" (the fast tier and its coverage gate) and -m integration never need a browser and the 80% floor keeps meaning "fast tests only". The UI tier starts a live server and a browser: the coding/test skills run it behind .integration-test.lock. Never weaken the axe check to get green — under enforce, fix the template/markup; a rule can only be narrowed with a documented reason at the call site.
  • Typed tests: use UserFactory.create()/.build() (factory_boy 3.3's generics give the model type), not UserFactory() (mypy sees the factory class).

Step 9 — Sphinx API docs

Write docs/api/conf.py, index.rst and the module pages from reference/docs-docker-and-sonar.md (the catalog page lists the API modules of the chosen api). conf.py puts the repository root on sys.path, sets DJANGO_SETTINGS_MODULE=config.settings.test and calls django.setup(), so autodoc imports models and views with no environment or database. Keep index.rst's title static ("API reference"): an <name>-length title underline breaks -W builds (Title underline too short, verified). Never create or edit Antora files (docs/antora.yml, docs/antora-playbook.yml, docs/modules/) — iru-setup-antora owns them; the workflows copy the Sphinx output under the Antora site's api/ path. When a module is added later, add an automodule line to the matching page.

Step 10 — Container image, compose, .env.example

From reference/docs-docker-and-sonar.md (written for every distribution; internal/store only decide whether the release workflow pushes the image to GHCR):

  • Dockerfile: multi-stage python:<python-version>-slim-trixie (same base in both stages), uv copied from ghcr.io/astral-sh/uv:<uv-version>, UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_NO_DEV=1 UV_PYTHON_DOWNLOADS=0, uv sync --locked --no-install-project (cached layer) then uv sync --locked, collectstatic at build time with throw-away values (never real secrets), non-root UID 10001 with root-owned code, DJANGO_SETTINGS_MODULE production, a stdlib urllib HEALTHCHECK on /healthz (no curl in slim), exec-form CMD ["gunicorn"] (reads gunicorn.conf.py). Migrations never run in the image's command.
  • .dockerignore: excludes .venv, .env* (keeps .env.example), caches, build/, dist/, staticfiles/, db.sqlite3, the UI tier's .ui-screenshots/ and test-results/, and docs/ (so Antora's docs/node_modules/ never enters the build context).
  • compose.yaml: db (postgres:18, volume at /var/lib/postgresql — PostgreSQL 18's path, not /data; pg_isready health check; published on 127.0.0.1:5432 for local.py), one-shot migrate (manage.py migrate --noinput, restart: "no", waits for a healthy db), and app (waits for migrate to complete successfully; port 8000). DJANGO_SECRET_KEY and POSTGRES_PASSWORD come from .env (${VAR:?} makes compose fail loudly when they're missing); DJANGO_HTTPS: "0" so the plain-HTTP stack works. SQLite variant: no db; migrate and app share a sqlite-data volume at /data (the image creates /data owned by UID 10001).
  • .env.example: placeholders only. .env stays untracked (iru-setup-python-gitignore ignores it) and never enters an image.

Step 11 — sonar-project.properties

Only when sonar is cloud/self-hosted, from reference/docs-docker-and-sonar.md: sources apps,config, tests tests, migrations excluded, settings/asgi.py/wsgi.py excluded from coverage, sonar.python.version=<python-version>, sonar.python.coverage.reportPaths=build/coverage/coverage.xml, sonar.python.xunit.reportPath=build/test-results/pytest*.xml, sonar.python.ruff.reportPaths=build/reports/ruff.json (the workflows write that report with ruff check . --exit-zero --output-format json --output-file build/reports/ruff.json). Omit sonar.organization for a self-hosted server without organizations. With sonar: none, write nothing (and handle an existing file per Step 1).

Step 12 — Format

uv run ruff check --fix . && uv run ruff format . — the templates are already clean; this catches anything adapted for an existing project. In an existing project, run uv run ruff format --check . first and, if it would touch files this skill didn't write, ask before reformatting (suggest a separate "apply Ruff format" commit).

Step 13 — Verify with the gate skills' commands

Run, in order, and fix anything this skill wrote until green — never "fix" pre-existing project code to make a gate pass; report it instead:

uv sync --locked
uv run ruff check .
uv run ruff format --check .
uv run mypy .
uv run python manage.py check
uv run python manage.py makemigrations --check --dry-run
DJANGO_SETTINGS_MODULE=config.settings.production \
  DJANGO_SECRET_KEY="$(uv run python -c 'import secrets; print(secrets.token_urlsafe(64))')" \
  DJANGO_ALLOWED_HOSTS=ci.example.com POSTGRES_HOST=localhost POSTGRES_PASSWORD=dummy \
  uv run python manage.py check --deploy --fail-level WARNING
mkdir -p build/api-docs
uv run python manage.py spectacular --validate --fail-on-warn --file build/api-docs/openapi.yaml       # api: drf
uv run python manage.py export_openapi_schema --api config.api.api --output build/api-docs/openapi.json  # api: ninja
uv run pytest -m "not integration" --cov --cov-report=xml --cov-report=term-missing --junitxml=build/test-results/pytest.xml
uv run pytest -m integration --junitxml=build/test-results/pytest-integration.xml    # database: postgresql, Docker
uv run playwright install chromium                                                    # once per machine (CI: --with-deps)
uv run pytest -m ui --junitxml=build/test-results/pytest-ui.xml                       # browser UI tier
uv run --group docs sphinx-build -W --keep-going -b html docs/api build/api-docs
uv build -o build/dist
docker build -t <name>:verify .                                                       # Docker
  • check --deploy needs no database connection — the dummy POSTGRES_* values only satisfy the fail-fast settings (the SQLite variant needs only the first three variables). The secret must be ≥ 50 characters and must not start with django-insecure- (security.W009).
  • spectacular --file and export_openapi_schema --output don't create directories — hence the mkdir -p (verified: FileNotFoundError without it).
  • Coverage: pytest-cov reads fail_under = 80 and the XML path from pyproject.toml; the run fails below the floor.
  • UI tier: expect .ui-screenshots/screen/AdminLoginScreen/{default,error}--{ltr,rtl}--{light,dark}.png (8 files, fewer when .ui-quality.yml narrows the matrix) and an a11y-report.jsonl with no violations under enforce. No browser (download blocked, unsupported host) → unverified (no browser), never passed.
  • Integration tier with no reachable Docker daemon: report it unverified (Docker unavailable), never passed. Same for the image build.
  • With Docker, also smoke-test the stack: write a throwaway .env (random DJANGO_SECRET_KEY, any POSTGRES_PASSWORD), docker compose up -d --build --wait, confirm curl -s localhost:8000/healthz → {"status": "ok", …}, /readyz → 200, the API list endpoint → 200, a /static/admin/... file → 200 (WhiteNoise), docker compose exec app id → uid 10001; then docker compose down -v and delete the throwaway .env unless the user wants to keep it. If port 8000 is taken, say so rather than editing compose.yaml.

Remove build/ and db.sqlite3 artifacts created by the checks only if they weren't there before (both are ignored anyway).

Step 14 — Report

Start with the mode announced in Step 1 (scaffolded from scratch or updated an existing setup) and the location. Then:

  • Per owned artifact, the action actually taken — created / updated / replaced / kept as is / removed — and, for every kept one, each mismatch with this run's choices.
  • The resolved choices: python-version (+ requires-python floor), database, api, server (WSGI gthread or ASGI Uvicorn worker), license, open-source, distribution (image built either way), sonar (with the visibility it was keyed on and where that came from; if ci: no and Sonar was still configured, say no CI step sends reports).
  • Versions from uv.lock (Django, DRF/drf-spectacular or Ninja, psycopg, pytest, pytest-django, factory_boy, testcontainers, pytest-playwright, Playwright, axe-playwright-python, Ruff, mypy, django-stubs, Sphinx, Gunicorn, WhiteNoise) and the uv image tag pinned in the Dockerfile.
  • .ui-quality.yml with its action (created / updated / replaced / kept as is) and the accessibility level applied (and where it came from: args, the user, or the default).
  • Every Step 13 command with its result: tests passed per tier (incl. the UI tier: captures written, axe violations by rule id), coverage %, mypy/ruff results, the Django checks, the schema export, Sphinx, uv build, the image build and smoke test — and anything unverified and why.

Finish with a Warn explicitly block:

  • Review before committing — pyproject.toml identity/license/authors, SPECTACULAR_SETTINGS/NinjaAPI title, the Sonar keys.
  • Commit uv.lock, .python-version, the migrations; never commit .env or db.sqlite3.
  • Production environment — the required variables above; HSTS with includeSubDomains + preload is on by default (lower DJANGO_SECURE_HSTS_SECONDS while rolling out HTTPS); set DJANGO_SECURE_PROXY_SSL_HEADER=1 only behind a proxy that strips X-Forwarded-Proto; probes and the image HEALTHCHECK send Host: 127.0.0.1 — keep it (or the probe host) in DJANGO_ALLOWED_HOSTS.
  • Migrations run once per release (the compose migrate service, a Kubernetes Job, a release step) — never in every replica's start command.
  • Integration tests need Docker (testcontainers pulls postgres:18-alpine).
  • UI tests need a browser: uv run playwright install chromium once (--with-deps on a bare Linux runner); .ui-screenshots/ is regenerated output (gitignored by iru-setup-python-gitignore), .ui-quality.yml is committed.
  • sonar: cloud/self-hosted: a SONAR_TOKEN secret and a matching project must exist before CI analysis succeeds.
  • No .gitignore, workflows, CHANGELOG.md, README.md or Antora files were written — iru-setup-python-gitignore, iru-setup-python-github-workflows, iru-setup-changelog, iru-setup-readme and iru-setup-antora cover them (the iru-setup-python-repository orchestrator runs them next). If no LICENSE exists, suggest iru-check-license.

Verification notes

Exercised on 2026-10-14 in $TMPDIR/iru-verify/python/setup-django/ (outside the repository) by rendering every reference/ template with the conventions above into fresh uv init --bare + startproject projects — the templates were extracted back out of the reference/*.md files and compared byte-for-byte with the verified sources first. Toolchain: uv 0.12.8, CPython 3.13.15, Docker 29.8.2; resolved Django 6.1.2, DRF 3.18.3, drf-spectacular 0.30.0, django-ninja 1.7.1 (Pydantic 2.14.1), psycopg 3.3.6, WhiteNoise 6.12.0, Gunicorn 26.2.0, uvicorn-worker 0.4.0 (Uvicorn 0.54.0), pytest 9.1.1, pytest-django 4.14.0, pytest-cov 7.1.0, factory_boy 3.3.3, testcontainers 4.15.0, Ruff 0.17.0, mypy 2.4.0, django-stubs 6.1.2, djangorestframework-stubs 3.18.1, Sphinx 9.1.0, sphinx-autodoc-typehints 3.13.10, uv_build 0.12.

Variant uv sync / ruff / format / mypy strict / check / makemigrations --check / check --deploy Schema Fast tier + coverage Integration Sphinx -W / uv build Docker
api: drf, database: postgresql all pass spectacular --validate --fail-on-warn passes 39 passed, 100% (gate 80%) 3 passed on postgres:18-alpine (connection.vendor == "postgresql" asserted) pass / sdist + wheel built; compose db healthy → migrate exited 0 → app healthy, /healthz + /readyz + /api/v1/books/ + WhiteNoise static 200, uid 10001
api: ninja, database: postgresql all pass export_openapi_schema passes 39 passed, 100% 3 passed pass / pass built; compose stack healthy, Gunicorn reports uvicorn_worker.UvicornWorker, /api/v1/books/ + /api/v1/docs 200
api: none, database: sqlite all pass n/a 31 passed, 100% none (exit 5, by design) pass / pass built; compose (migrate + app on the /data volume) healthy, /readyz 200

Also run: uv run pytest (both tiers in one session) — 42 passed, all on PostgreSQL. Not verified: api: drf/ninja with database: sqlite beyond what the shared templates cover (the tagged lines are the same ones exercised above), python-version 3.12/3.14, the update path against a real pre-existing project (Steps 1/5/6 merge rules are instructions, not templates), Poetry/pip-managed projects, and anything on a Linux CI runner (the workflows skill covers that).

UI tier, exercised on 2026-10-16 in $TMPDIR/iru-verify/python/iru-setup-python-django/ on the rendered api: drf, database: postgresql project plus reference/ui-tests.md and the LocaleMiddleware/marker/mypy-override changes (pytest-playwright 0.10.0, Playwright 1.64.0 with its bundled Chromium, axe-playwright-python 0.1.8 bundling axe-core 4.12.1): ruff check/format, mypy strict (48 files), manage.py check, makemigrations --check, check --deploy --fail-level WARNING all pass; pytest -m "not integration" 39 passed / 11 deselected (3 integration + 8 UI), 100% coverage — unchanged; pytest -m ui 8 passed, writing the 8 AdminLoginScreen PNGs (the rtl captures show the Arabic admin with dir="rtl", the dark ones Django's dark palette) and zero axe violations on the admin login page in both states, both directions and both themes, so enforce needs no rule exclusion; an injected page with an unlabelled button and an image without alt failed with button-name, document-title, image-alt. Observed: Accept-Language set through page.set_extra_http_headers is overridden by the context locale in Chromium (the RTL captures stayed dir="ltr") — hence the language cookie. Not verified: the UI tier on a Linux runner and on the ninja/sqlite shapes (the files are identical; the admin is the same), and the plain uv run pytest integration run on this machine (testcontainers' PostgreSQL container exited during start-up — a local Docker issue unrelated to the UI tier, which that run correctly deselected).

Sources