Skip to main content
These notes capture intent behind major choices. They help new contributors and integrators understand tradeoffs without reading every module.

Multi-tenancy: one database, schema per school

Decision: Use django-tenants with a PostgreSQL schema per tenant and a public schema for shared data. Why:
  • Strong isolation — School A’s data is not queryable from school B’s schema by accident; boundaries are enforced at the database layer, not only in application code.
  • Operational simplicity — One database cluster to backup and migrate; migrate_schemas applies tenant migrations across all schools.
  • Fits PostgreSQL — Schema-based tenancy is a well-understood pattern on Postgres.
Tradeoff: Cross-tenant reporting or analytics in SQL is harder; anything “global” must live in public or be aggregated deliberately.

Two API surfaces: system vs school

Decision: Split HTTP APIs under /api/v1/system/ (platform) and /api/v1/school/ (tenant operations). Why:
  • Clear security story — System endpoints assume public schema and platform roles; school endpoints assume tenant schema and school RBAC.
  • Predictable URLs — Operators and generated clients can distinguish governance from day-to-day school work.
  • Aligns with middlewareSmartTenantMiddleware keeps system (and docs) routes on public while resolving tenant context for school traffic.

Tenant resolution in middleware

Decision: Custom SmartTenantMiddleware (extends django-tenants) resolves schema using, in order: global path list (stay on public), then JWT (user’s school), X-Schema-Name (superadmin), then hostname → Domain → Client. Why:
  • JWT-first for APIs — Mobile and SPA clients often hit a single host; the token carries identity, and the user’s linked client determines the schema.
  • X-Schema-Name for support — Superadmins can target a school without juggling DNS.
  • Host-based fallback — Matches classic subdomain-per-school deployments.
Note: Chat traffic uses /api/v1/chatbot/. Tenant context for those requests follows the same middleware rules as other non-system API traffic (host, JWT, X-Schema-Name); individual views may also resolve a school using phone numbers or payloads where the product requires it.

Django REST Framework with ViewSets and routers

Decision: Expose the API primarily through DRF ModelViewSets registered on DefaultRouter and NestedDefaultRouter. Why:
  • Consistency — List/create/retrieve/update/delete patterns repeat across many resources.
  • Nested URLs — Academic structure is naturally hierarchical (session → academic class → registration; session → exam session → exam → marks).
  • Less boilerplate than hand-written function views for CRUD-heavy domains.
Tradeoff: Deep nesting can make URLs long; OpenAPI and docs must stay in sync via regeneration.

Permissions: Django model permissions by HTTP method

Decision: Default permission class DynamicModelPermissions maps GET/POST/PUT/PATCH/DELETE to view_ / add_ / change_ / delete_ model permissions. Why:
  • Single source of truth — Authorization reuses Django’s permission tables and school roles.
  • Auditable — Permissions are data, not scattered if statements in every view.
Some viewsets use specialized permission classes (for example broader read access where it matches product needs).

Authentication: JWT plus optional API keys

Decision: Simple JWT for user sessions (Bearer tokens); platform-managed API keys for integrations (documented in the API reference). Why:
  • Stateless API — Fits mobile apps and third-party servers.
  • Separation — Human flows use login/refresh; automation can use scoped keys where supported.
Token lifetimes and header format are centralized in sms/settings.py under SIMPLE_JWT.

OpenAPI and documentation

Decision: drf-spectacular generates OpenAPI 3; the repo commits docs/api-reference/openapi.json for Mintlify; live servers expose Swagger/ReDoc at /api/docs/ and /api/redoc/. Why:
  • Contract for integrators — A single schema drives Mintlify’s API tab and can drive client generation.
  • Hooks for qualityPREPROCESSING_HOOKS / POSTPROCESSING_HOOKS trim or annotate the schema (for example tenant header, tag ordering) so published docs stay readable.
Tradeoff: The committed JSON can drift until someone runs manage.py spectacular; treat regeneration as part of the API change workflow.

Exception handling and error shape

Decision: global_exception_handler wraps DRF’s handler, delegates to core where needed, and attaches status_code to JSON responses when the payload is a dict. Why:
  • Consistent client parsing — Callers can rely on HTTP status and a structured body.
  • Swagger UX — Special handling for docs routes triggers browser-friendly 401 + WWW-Authenticate for protected Swagger.

Async work: Celery and Redis

Decision: Celery with Redis (when configured) for background work such as chatbot pipelines and report generation. Why:
  • Non-blocking HTTP — Long-running PDFs or messaging should not tie up request workers.
  • Operational flexibility — Workers scale independently of the web tier.
Local development can omit Redis/Celery for many flows; production should enable them for full behaviour.

Static files and Swagger assets

Decision: Use drf-spectacular-sidecar and WhiteNoise so Swagger/ReDoc assets ship reliably in production. Why:
  • No CDN dependency for core API docs in locked-down environments.

Audit logging

Decision: AuditMiddleware (after authentication) records sensitive actions for compliance and debugging. Why:
  • Accountability — School operations often require “who changed what and when.”