Skip to content

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.

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_*.py

A 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.

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.

ElementRule
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.mdcommands, 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.

SituationDeclarationdeptry ignore
Hard runtime import (including lazy in-function, no fallback)dependenciesnone
Soft import (try/except + graceful degradation: skip + log/event)extra in optional-dependenciesDEP001 ignore with a comment
Schema-only dependency (FK string ref, M2M, migration dependencies: zero Python imports)dependenciesDEP002 ignore with a comment
Import only in teststest extranone

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).

  • Never edit a released migration: a schema change means a new file.
  • Migration file names are part of the contract (django_migrations in existing databases): don’t rename them.
  • Squashing follows the replaces mechanism, see below.
  • Proxy models have migrations too. A proxy registered in the admin materializes only with django.contrib.admin in INSTALLED_APPS. The module’s test settings MUST include it, otherwise makemigrations --check misses 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 its make test.

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:

Scenariomigrate behavior
Existing database (old chain recorded in django_migrations)zero operations; the squash is only recorded as applied
Fresh installationexecutes the squash (schema + seed)
Other apps’ migrations depending on old namesthe loader remaps dependencies onto the squash

Rules:

  • Manual squash, not squashmigrations: the automatic tool leaves RunPython references to old files. Compose it instead: makemigrations on a clean state (final schema 1:1 with models) + data operations moved into the one file + replaces.
  • replaces stays 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 dependencies of the squash must target the migration that creates the FK target, not the target app’s leaf. makemigrations wires an FK dependency to the target app’s latest migration; if that app has post-squash migrations, the squash (marked applied via replaces on existing databases) would “overtake” a not-yet-applied migration and migrate fails with InconsistentMigrationHistory. 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 (setval to max(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).
  • Pattern: the module’s settings.py reads getattr(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 ## Settings section of AGENTS.md, or in docs/ when the list outgrows it (AGENTS.md Convention).

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_area on every admin view, plus access_levels for 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-access in dependencies, once the coverage test imports it.

  • Prove coverage in the module’s own tests, with django_access in the test settings’ INSTALLED_APPS:

    from django_access.testing import assert_routes_covered
    def 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.