The platform uses a multi-tenant architecture where each sector (GP, dental, client-test) has its own PostgreSQL database. Tenants within a sector share that sector's database.
| Database alias | Purpose | Host |
|---|---|---|
default |
Global registry (users, auth, tenancy) | postgres:5432 |
sector_gp |
GP sector data | postgres:5432 (same as default) |
sector_dental |
Dental sector data | postgres:5432 |
sector_client |
Client/test tenant data | 172.18.0.1:15432 |
Historically, most application data was stored in the default / sector_gp database, even for client/test tenants. The re-alignment moves client-test data into the dedicated sector_client database.
Objects in different databases have different primary keys for the same logical entity. For example, Jack Hall is user 63 in default but user 14 in sector_client. This means:
allow_relation checks fail because _state.db values differdb_alias ParameterForms that query tenant-specific data need to know which database to use.
# appointments/forms.py
def __init__(self, *args, **kwargs):
db_alias = kwargs.pop('db_alias', 'default')
super().__init__(*args, **kwargs)
self.db_alias = db_alias
if self.instance and self.instance._state.db is None:
self.instance._state.db = db_alias
self.fields['clinic'].queryset = Clinic.objects.using(db_alias).filter(is_active=True)
Pass db_alias from the view's get_form_kwargs:
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
tenant = get_current_tenant()
user_db = 'default'
if tenant and tenant.sector.slug != 'gp':
user_db = f"sector_{tenant.sector.slug}"
kwargs['db_alias'] = user_db
return kwargs
When a URL contains an ID from the default DB (e.g. ?patient=63), and the form validates against the tenant DB, the ID must be translated:
patient_id = self.request.GET.get('patient')
dbs = ['default']
if tenant and tenant.sector.slug != 'gp':
dbs.insert(0, f"sector_{tenant.sector.slug}")
for db in dbs:
try:
patient = CustomUser.objects.using(db).get(
id=patient_id, role=CustomUser.Roles.PATIENT
)
break
except CustomUser.DoesNotExist:
continue
form_validBefore assigning FK fields (clinician, patient, etc.), resolve them to the tenant database:
clinician = self.request.user
if tenant and tenant.sector.slug != 'gp':
target_db = f"sector_{tenant.sector.slug}"
local_clinician = UserModel.objects.using(target_db).filter(
username=clinician.username, is_active=True
).first()
if local_clinician:
clinician = local_clinician
form.instance.prescribing_clinician = clinician
.using()Dashboard views need to query the tenant DB, not the default:
tenant = get_current_tenant()
target_db = 'default'
if tenant and tenant.sector.slug != 'gp':
target_db = f"sector_{tenant.sector.slug}"
todays_appointments = Appointment.objects.using(target_db).filter(...)
Resolve the practice's clinic to the tenant DB:
clinic_local = Clinic.objects.using(target_db).filter(
name=practice.clinic.name, is_active=True
).first()
When a clinician views patient records, their user ID must be resolved to the tenant DB:
local_clinician = CustomUser.objects.using(db_alias).filter(
username=user.username, is_active=True
).first()
clinician_filter = local_clinician or user
patient_ids = Appointment.objects.using(db_alias).filter(
clinician=clinician_filter
).values_list('patient_id', flat=True)
The audit signal handler's jsonify_details must recursively handle nested dicts (e.g. medication_details containing datetime objects):
def jsonify_details(details_dict):
for key, value in details_dict.items():
if isinstance(value, dict):
details_dict[key] = jsonify_details(value)
elif isinstance(value, (date, datetime)):
details_dict[key] = value.isoformat()
...
The sign_with_aes method stores signed_at from the AES portal response. Convert to string before storing in medication_details:
signed_at = result.get('signed_at')
if hasattr(signed_at, 'isoformat'):
signed_at = signed_at.isoformat()
prescription.medication_details['aes_signed_at'] = signed_at
| File | Change |
|---|---|
appointments/forms.py |
Added db_alias kwarg, set _state.db on instance, scope clinic/appointment_type querysets |
appointments/views.py |
Added get_form_kwargs passing db_alias, resolved patient ID to tenant DB in get_initial, scoped patient/clinician querysets in get_form, resolved patient/clinician in form_valid |
| File | Change |
|---|---|
clinical_data/views.py |
Resolved patient ID across DBs in get_form_kwargs and get_context_data, scoped patient queryset in get_form, resolved clinician in form_valid, added tenant-aware get_queryset to PrescriptionDetailView |
clinical_data/services.py |
Convert signed_at to isoformat before storing in JSONField |
| File | Change |
|---|---|
dashboards/views/dashboards.py |
Made receptionist and clinician dashboard queries tenant-aware using .using(target_db), resolved clinic and clinician to tenant DB |
| File | Change |
|---|---|
users/views.py |
Resolved clinician to tenant DB in PatientDetailView.get_queryset |
| File | Change |
|---|---|
auditing/signals.py |
Made jsonify_details recursive to handle nested dicts with date/datetime values |
| File | Change |
|---|---|
docker-compose.yml |
Added aes-service_aes-network to app container for AES portal connectivity |
The entire ICB model and all its references were removed across ~30 files (models, views, serializers, api, admin, templates, migrations, management commands). See ICB Removal for details.
SectorDatabaseRouter is not registered — tenancy/routers.py defines SectorDatabaseRouter but it is not added to DATABASE_ROUTERS in settings. Enabling it would automate database routing but requires staff role records and other data to exist in each tenant database.
Data seeding — Tenant databases lack proper seed data:
default, not in sector_clientICB model remnants — Check django_migrations table; some migration files referenced in the DB may no longer exist in the repo.
Multi-tenant query audit — Every model query that doesn't use .using() may silently return data from the wrong database. Key areas to audit:
ListView and DetailView get_queryset methodsWhen testing a new feature or view, verify: