Saltar a contenido

Database Routing

This page describes TenantDatabaseRouter (apps/companies/router.py) — how Hardhat Flow decides which physical MySQL database a query hits, and the one exception to the platform/tenant split.


The Split

Hardhat Flow has one platform database (default) and one MySQL database per tenant (alias tenant_<slug>).

  • PLATFORM_APP_LABELS (companies, accounts, admin, auth, contenttypes, sessions, django_celery_beat) — these app labels always read/write default, regardless of any tenant context.
  • Everything else (bids, jobs, invoices, photos, activity, …) reads/writes whichever tenant DB alias is set on the request's thread-local (set_current_tenant_db, set by TenantMiddleware/auth). If no tenant alias is set, it falls back to default.
  • allow_migrate(db, app_label) decides which app's tables get created on which physical database when you run migrate: platform labels only on default, everything else only on tenant_* aliases.

See also Cross-DB User FK Pattern for the related problem of select_related/lazy access across this split.


DUAL_MIGRATE_APP_LABELS — the one exception

activity (the ActivityLog / Update models) is a tenant-routed app label — it is not in PLATFORM_APP_LABELS, so db_for_read/db_for_write still send it to the tenant DB by default. Every bid/job/invoice/photo activity entry lives in that tenant's own database, same as the rest of that tenant's data.

However, a handful of platform-admin actions in apps/companies/views.py — tenant provisioning, tenant soft-delete, and role changes performed by a Platform Admin — write to ActivityLog.objects.using("default") deliberately, to build a platform-level audit trail (PlatformAuditLogView) that's separate from any single tenant's activity feed.

For those .using("default") writes to work, the activity_activitylog table has to physically exist on default too — but allow_migrate alone would never create it there, because activity isn't a platform app label. DUAL_MIGRATE_APP_LABELS = frozenset({"activity"}) fixes that: it's OR'd into the db == "default" branch of allow_migrate only, so the activity app's tables get created on both default and every tenant_* DB, while db_for_read/db_for_write are untouched and still route to the tenant DB by default.

def allow_migrate(self, db, app_label, **hints):
    if db == "default":
        return app_label in PLATFORM_APP_LABELS or app_label in DUAL_MIGRATE_APP_LABELS
    if db.startswith("tenant_"):
        return app_label not in PLATFORM_APP_LABELS
    return None

Do not add activity to PLATFORM_APP_LABELS instead — that would re-route every tenant's activity writes (bids, jobs, invoices, photos, accounts) to the platform DB, mixing all tenants' activity logs into one table. DUAL_MIGRATE_APP_LABELS only affects table creation, not query routing.

Where each kind of ActivityLog row lands

Action View DB
Tenant owner edits company settings CompanySettingsUpdateView.patch tenant DB (default routing)
Tenant owner edits notification preferences NotificationSettingsView.patch tenant DB (default routing)
Platform Admin provisions/deletes a tenant TenantDetailView default (.using("default"), platform audit trail)
Platform Admin changes a tenant user's role tenant user role-change view default (.using("default"), platform audit trail)
PlatformAuditLogView.get reads default

Rule for new code

Writing (or reading) a tenant-routed model with an explicit .using("default") requires that app label be added to DUAL_MIGRATE_APP_LABELS, or the write will hit django.db.utils.ProgrammingError: (1146, "Table '...' doesn't exist") in any environment where default and the tenant DB are genuinely separate databases (production; not the test suite, which runs with DATABASE_ROUTERS = [] and a single in-memory DB — see config/settings_test.py).


Incident: 2026-08-21 1146 on /platform/settings/notifications/

NotificationSettingsView.patch and CompanySettingsUpdateView.patch used .using("default") for their ActivityLog.objects.create(...) calls even though they're tenant-facing, request.tenant-scoped views — not platform-admin actions. Since activity wasn't in any migrate-to-default set, defaultdb.activity_activitylog never existed, and every PATCH to those endpoints 500'd. Fixed by removing .using("default") from those two call sites (they now use default routing, landing in the tenant DB like every other tenant activity entry) and adding DUAL_MIGRATE_APP_LABELS so the genuinely-platform writes (TenantDetailView, role-change, PlatformAuditLogView) have a table to write to.