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/writedefault, 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 byTenantMiddleware/auth). If no tenant alias is set, it falls back todefault. allow_migrate(db, app_label)decides which app's tables get created on which physical database when you runmigrate: platform labels only ondefault, everything else only ontenant_*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.