<critical_constraints> FORBIDDEN: Truncating code or writing placeholders such as "// ... existing code ..." or "# rest unchanged". Every edit is complete and applies as written. FORBIDDEN: Reporting a check as passed without showing the command and its output. REQUIRED: Reason through the rules below before the first edit; when two rules conflict, the one stated first wins. </critical_constraints>
#Purpose
Rules for a Django codebase that stays coherent past the third app. Django's batteries are good; the failure mode is fighting them — hand-rolled auth, raw SQL where the ORM would do, business logic in views.
Python-level conventions are Backend/python-conventions; REST specifics are
API/rest.
#Layout
arduinoconfig/
settings/base.py dev.py prod.py # split, not one file with `if DEBUG`
urls.py asgi.py wsgi.py
apps/
orders/
models.py services.py selectors.py admin.py urls.py views.py
migrations/ tests/
- Each app owns one bounded concept. If
orders/models.pyimports frombilling/models.pyand vice versa, you have one app pretending to be two. services.pyholds writes (create/update with rules),selectors.pyholds reads. Views parse, call, format — nothing else.DJANGO_SETTINGS_MODULE=config.settings.prodin production;dev.pyimports*frombase.pyand overrides.
#Models and migrations
pythonclass Order(models.Model):
tenant = models.ForeignKey("tenants.Tenant", on_delete=models.PROTECT)
status = models.CharField(max_length=16, choices=Status.choices, db_index=True)
total_cents = models.PositiveIntegerField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.CheckConstraint(check=Q(total_cents__gte=0), name="order_total_nonneg"),
models.UniqueConstraint(fields=["tenant", "external_id"], name="order_ext_id_uniq"),
]
- Every
ForeignKeynames itson_deletedeliberately.CASCADEon a tenant or user deletes history;PROTECTmakes the deletion a decision. - Invariants go in
Meta.constraints, enforced by the database, not only inclean(). →Database/schema-design - Money is integer cents, never
FloatField. makemigrationsoutput is reviewed like code: rename operations, data migrations, and index additions on large tables (AddIndexConcurrentlyon Postgres) are where deploys go wrong. Never edit an applied migration.- Data migrations use
apps.get_model(), not the live model import — the live model may have fields the migration's schema does not.
#Querysets — the N+1 rule
python# N+1: one query for orders, one per order for its customer
for order in Order.objects.filter(tenant=t):
print(order.customer.email)
# Fixed
orders = (Order.objects.filter(tenant=t)
.select_related("customer") # FK / one-to-one → JOIN
.prefetch_related("items__product") # reverse / many → 2nd query
.only("id", "status", "customer__email"))
select_relatedfor forward foreign keys;prefetch_relatedfor reverse relations and many-to-many. Using the wrong one either explodes the row count or issues N queries.- Assert query counts in tests:
with self.assertNumQueries(2):. A view that passes at 3 queries and fails at 300 in production is an N+1 you did not test. .exists()notlen(qs);.count()notlen(list(qs));.update()for bulk writes instead of a loop of.save()..iterator(chunk_size=2000)for exports; a plain loop over a million rows caches every instance in memory.- Never call the ORM in a template tag or a model
__str__that the admin lists — that is a hidden N+1.
#Views, forms, serializers
python@require_POST
@login_required
def create_order(request: HttpRequest) -> HttpResponse:
form = OrderForm(request.POST)
if not form.is_valid():
return render(request, "orders/new.html", {"form": form}, status=400)
order = orders.services.create_order(actor=request.user, **form.cleaned_data)
return redirect(order)
- Validation lives in a
Form/ModelForm(HTML) or a DRFSerializer(JSON). Readingrequest.POST["x"]directly skips validation and type coercion. - Views never contain business rules. A rule in a view is unavailable to the management command, the Celery task, and the test that needs it.
- Class-based views for CRUD that fits the generic ones; function views for
anything with branching. Do not subclass
Viewfive levels deep. get_object_or_404with the tenant filter applied — a lookup bypkalone is an IDOR. →Security/authorization
#Security middleware and settings
Django ships these on. Keep them on, and set the ones that are off:
| Setting | Production value |
|---|---|
DEBUG | False — a True leaks settings and source on every 500 |
ALLOWED_HOSTS | Explicit list, never ["*"] |
SECRET_KEY | From the environment; rotated if ever committed |
SECURE_SSL_REDIRECT, SECURE_HSTS_SECONDS | True, 31536000 |
SESSION_COOKIE_SECURE, CSRF_COOKIE_SECURE | True |
SECURE_PROXY_SSL_HEADER | ("HTTP_X_FORWARDED_PROTO", "https") behind a proxy |
CsrfViewMiddleware | Present — @csrf_exempt is a code review item |
Run python manage.py check --deploy in CI; it fails on most of the above.
→ Security/headers
#Admin
python@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ("id", "status", "customer_email", "created_at")
list_select_related = ("customer",)
list_filter = ("status",)
search_fields = ("id", "customer__email")
raw_id_fields = ("customer",)
readonly_fields = ("created_at",)
list_select_relatedfor every FK shown inlist_display, or the changelist is one query per row.raw_id_fields/autocomplete_fieldsfor any FK with more than a few hundred rows; the default<select>renders every one.- The admin is a staff tool, not a public API. Put it on a non-default path and behind SSO or an allowlist in production.
#Testing
pythonclass CreateOrderTests(TestCase):
def test_rejects_other_tenants_customer(self):
other = CustomerFactory(tenant=TenantFactory())
with self.assertNumQueries(1):
with self.assertRaises(PermissionDenied):
create_order(actor=self.user, customer_id=other.id, items=[...])
TestCase wraps each test in a transaction and rolls back — fast and isolated.
Use TransactionTestCase only when testing transaction behaviour itself. Build
data with factories, not fixtures files. → Testing/pytest
#Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
One settings.py with if DEBUG: | Prod and dev drift silently | Split settings modules |
| Business rules in views | Unreachable from tasks and tests | services.py |
on_delete=CASCADE on tenant/user | Deletes history | PROTECT |
Invariants only in clean() | Bypassed by .update() and shell | Meta.constraints |
| Loop over queryset touching FKs | N+1 | select_related/prefetch_related |
len(qs) for a count | Loads every row | .count() / .exists() |
| Editing an applied migration | Other environments diverge | New migration |
| Live model import in data migration | Schema mismatch | apps.get_model() |
FloatField for money | Rounding errors | Integer cents |
request.POST["x"] in a view | No validation or coercion | Form / serializer |
get_object_or_404(Order, pk=pk) | IDOR across tenants | Filter by tenant |
ALLOWED_HOSTS = ["*"] | Host header attacks | Explicit list |
@csrf_exempt on a session-auth view | CSRF | Keep the middleware |
Admin FK without raw_id_fields | Renders every row in a select | autocomplete_fields |
TransactionTestCase everywhere | 10× slower suite | TestCase |
#Checklist
- Settings split into
base/dev/prodmodules - Each app owns one concept; no circular model imports
- Writes go through
services.py; views hold no business rules - Every
ForeignKeysetson_deletedeliberately - Invariants enforced with
Meta.constraints - Money stored as integer cents
- Migrations reviewed; applied migrations never edited
- Data migrations use
apps.get_model() - List views use
select_related/prefetch_related; query counts asserted - Bulk operations use
.update(),.bulk_create(),.iterator() - All input passes through a
FormorSerializer - Object lookups are scoped to the tenant
-
manage.py check --deploypasses in CI - Admin changelists set
list_select_relatedandraw_id_fields - Tests use
TestCaseand factories