Django Module Standard
An entirius-django-* module is an installable Django app with one layout: models, schemas, services and API in
separate packages, tests next to the package. The rules hold for a new module and for new code in an existing one.
Layout
Section titled “Layout”entirius-django-<name>/├── pyproject.toml├── Makefile├── README.md, CHANGELOG.md, AGENTS.md, CLAUDE.md, LICENSE├── docs/ optional├── src/django_<name>/│ ├── apps.py│ ├── urls.py│ ├── settings.py the settings the module reads│ ├── models/│ ├── migrations/│ ├── schemas/ requests/, responses/ (Pydantic)│ ├── services/ business logic, called by the views│ └── api/│ ├── admin/ urls.py, views/│ └── public/ urls.py, views/└── tests/ settings.py, conftest.py, test_*.pyA view validates the request with a schema, calls a service and returns a response schema. Models hold data and
services hold the rules; a view holds neither. A module with no storefront endpoints has no api/public/.
Build a Module writes every file of this tree for a small module, step by step.
entirius-django-faq is the smallest released module in this
shape. entirius-django-pim has the same packages next to code
that predates the layout.
The Schema Contract
Section titled “The Schema Contract”Import name = app_label = DB table prefix. Never change it.
Only the distribution name (entirius-django-<name>) is changeable.
It is the usual open-source idiom: djangorestframework is imported as rest_framework.
Repository Structure
Section titled “Repository Structure”| Element | Rule |
|---|---|
src/<package>/ | module code; hatchling packages the whole directory (templates/static included automatically) |
tests/ | outside the package, so test code and test dependencies (factory-boy) stay out of the installed package. A few older modules still carry tests inside src/<package>/ as well; new tests go to tests/ |
docs/ | optional; the module’s own reference, with no pointer to a document a visitor cannot open |
AGENTS.md + CLAUDE.md | commands, conventions, architecture, settings and gotchas (AGENTS.md Convention); the full settings reference moves to docs/ when it does not fit |
Dependencies: Declarations == Real Imports
Section titled “Dependencies: Declarations == Real Imports”What pyproject.toml declares matches what the code imports. The convention is written in
deptry terms (DEP001 is an import without a declaration, DEP002 a declaration without an
import), and a module records its deliberate exceptions under [tool.deptry]. No Makefile target, hook or CI job
runs deptry, so review is what holds the convention.
| Situation | Declaration | deptry ignore |
|---|---|---|
| Hard runtime import (including lazy in-function, no fallback) | dependencies | none |
| Soft import (try/except + graceful degradation: skip + log/event) | extra in optional-dependencies | DEP001 ignore with a comment |
Schema-only dependency (FK string ref, M2M, migration dependencies: zero Python imports) | dependencies | DEP002 ignore with a comment |
| Import only in tests | test extra | none |
Every deptry ignore carries a “why” comment in pyproject.toml.
Re-exports in output*.py modules are a public contract for consumers. An import unused in
its own file (F401) may be a deliberate re-export for other modules. Mark it
# noqa: F401 with a “who uses this” comment, and never remove it during linting or cleanup: the module that
imports the symbol fails with an ImportError after the next release.
Entry points: [project.entry-points."…"] is a runtime-discovery dependency: registries
(e.g. connectors) fail without them only at runtime/tests, silently. Always in
pyproject.toml.
Ecosystem deps: check upstream extras (e.g. django-allauth[socialaccount]: the bare dist is
not enough for social login).
Migrations = Public Contract
Section titled “Migrations = Public Contract”- Never edit a released migration: a schema change means a new file.
- Migration file names are part of the contract (
django_migrationsin existing databases): don’t rename them. - Squashing follows the
replacesmechanism, see below. - Proxy models have migrations too. A proxy registered in the admin materializes only with
django.contrib.admininINSTALLED_APPS. The module’s test settings MUST include it, otherwisemakemigrations --checkmisses the drift. - The drift check (
makemigrations --check --dry-run) is a pytest test in a new module, as Build a Module writes it. Most released modules have no such test yet; the service runs the same check over every installed module as the first step of itsmake test.
Squash with replaces
Section titled “Squash with replaces”A public module ships one migration (the full final schema plus data operations) with a
replaces attribute listing the entire historical chain. Django’s check_replacements
mechanism makes this safe for every consumer:
| Scenario | migrate behavior |
|---|---|
Existing database (old chain recorded in django_migrations) | zero operations; the squash is only recorded as applied |
| Fresh installation | executes the squash (schema + seed) |
| Other apps’ migrations depending on old names | the loader remaps dependencies onto the squash |
Rules:
- Manual squash, not
squashmigrations: the automatic tool leavesRunPythonreferences to old files. Compose it instead:makemigrationson a clean state (final schema 1:1 with models) + data operations moved into the one file +replaces. replacesstays forever: removing it would turn the squash into an “unapplied” migration on existing databases.- Assumption: the target database is migrated to the end of the old chain. Intermediate states cannot be reproduced from a squash without the old files.
- Data operations that are a no-op on a fresh database are skipped, with a comment in the squash explaining why.
- Cross-app
dependenciesof the squash must target the migration that creates the FK target, not the target app’s leaf.makemigrationswires an FK dependency to the target app’s latest migration; if that app has post-squash migrations, the squash (marked applied viareplaceson existing databases) would “overtake” a not-yet-applied migration andmigratefails withInconsistentMigrationHistory. Fix the dependency by hand to the migration creating the FK table. - Postgres-only data operations (e.g. sequence resets) need a
schema_editor.connection.vendor == "postgresql"guard: a fresh install on sqlite must work. - A seed with explicit PKs leaves the postgres sequence at its start value: the first INSERT
without a PK collides. A squash with seeds must end its data payload with a sequence reset
(
setvaltomax(id), vendor-guarded), and be verified on postgres; sqlite cannot detect this class of bug.
- pytest + pytest-django; test settings in
tests/settings.py. - Database via
DATABASE_URL(dj_database_url.config(default="postgresql://postgres:postgres@localhost:5432/test")). CI brings a postgres service, locally any postgres 15+. - Postgres is mandatory when the code uses pg-only features:
DISTINCT ON,LEAST/GREATEST,JSONField contains,nulls_distinct, raw SQL. Sqlite only for modules without such patterns. - No hardcoded DB credentials or hosts in test settings.
- A module without tests at minimum gets an import smoke test (parameterized
importlib).
Module Settings
Section titled “Module Settings”- Pattern: the module’s
settings.pyreadsgetattr(settings, "NAME", default). - A setting without a sensible default raises with a clear message at import time
(fail-fast; e.g.
PRIVATE_DIR,JWT_SECRET). - The settings the module reads, with defaults, are listed in the
## Settingssection ofAGENTS.md, or indocs/when the list outgrows it (AGENTS.md Convention).
Access Areas
Section titled “Access Areas”Applies from entirius-django-access 1.0.0. Why the gate exists:
Access and API Tokens.
Access is a core module, like entirius-django-utils. A module that serves admin routes owns their access mapping:
-
Declare the areas on the module’s own
AppConfig. Plain dicts, no import needed:class FaqConfig(AppConfig):name = "django_faq"access_areas = [{"key": "faq.faq", "label": "FAQ"},{"key": "faq.export", "label": "FAQ export", "levels": ("write",), "sensitive": ("pii",)},] -
Put
access_areaon every admin view, plusaccess_levelsfor a method whose default lies (a POST that only reads):class QuestionViewSet(viewsets.ViewSet):authentication_classes = [JWTAuthentication]permission_classes = [IsAdminUser]access_area = "faq.faq"access_levels = {"POST": "read"}The view attribute wins over path rules in the
AppConfig(access_route_rules), which win over the access module’s defaults. Use path rules only where no view can carry the attribute (Django admin pages, third-party views, DRF router roots). -
Depend on it:
entirius-django-accessindependencies, once the coverage test imports it. -
Prove coverage in the module’s own tests, with
django_accessin the test settings’INSTALLED_APPS:from django_access.testing import assert_routes_covereddef test_admin_routes_are_covered():assert_routes_covered("django_faq", require_own=True) -
No second check. Keep the view’s
IsAdminUser; never add a permission class that re-checks areas or roles. Object-level rules (a channel the user may not see, a record’s state) stay in the module’s services.
This holds for a new module and for a new admin route in an existing one. Modules that owned admin routes before
access still live on the access defaults and move their rules into their own code at their next regular release;
manage.py check lists the ones left (django_access.I001). Field reference, precedence and the failure
reasons of assert_routes_covered:
module-authors.md.
- Build a Module: the layout built file by file
- API v2 Standard: the rules for the views and schemas
- Python Toolchain: uv, ruff, pytest, pre-commit