Skip to content

Architecture

HTMX templates              Ninja API endpoints (grow as needed)
       \                       /
        \                     /
         Services layer (thin views call into this)
                   |
                   v
              Django ORM
                   |
                   v
            SQLite (Fly volume) → Postgres when needed

Server-rendered templates with HTMX for partial updates, Alpine.js for small client-side state (dropdowns, modals). Services are plain Python functions in apps/<app>/services.py. Models are Django ORM with custom managers for household scoping.

Apps

App What lives here
apps/common/ Middleware (current-household context), household-scoped managers, encrypted text field, shared context processors, static template checks
apps/households/ Household, HouseholdMembership, Person; signup service; dashboard view; invite tokens
apps/auth_google/ "Sign in with Google" flow (openid email profile); GoogleIdentity keyed by Google sub. Independent from emailscan's Gmail OAuth.
apps/documents/ Document model; upload + confirm views; topic / category / search / timeline / person / named-item views; bulk operations; auto-assign; activity timeline
apps/classifier/ Claude Sonnet integration. Stub mode keyword-based; live mode sends bytes + email context with prompt caching.
apps/emailscan/ Gmail OAuth + scan pipeline. Heuristic pre-filter, thread + content-hash dedup, recurring-email dedup, Claude classification, lifecycle bucketing.
apps/triage/ Triage UI: overview / review / batch-accept / per-bucket counts. Adapter delegates to emailscan in prod, stub in tests.
apps/profiles/ PropertyProfile + VehicleProfile sidecars. Schedule definitions for houses (schedules/house.py) and vehicles (schedules/vehicle.py). DVLA + DVSA lookup service.
apps/activity/ ActivityEvent model + per-event undo (auto-assign undo, confirm-review undo).
apps/api/ Django Ninja API surface. One /api/health endpoint today; grows as consumers need them.

Data model highlights

  • Household / Person / NamedItem — the multi-tenant root. Every Document, EmailScanFinding, ActivityEvent has a household FK. NamedItem.kind is house or vehicle.
  • Document — the unit of filing. Has topic, document_type_id, provider, issue_date, expiry_date, lifecycle_state, filing_status, reviewed_at, named_item, person. Carries the AI's proposal AND the user's eventual confirmation.
  • EmailScanFinding — a candidate document found in Gmail awaiting Triage decision. Holds the AI's proposed metadata; on accept it becomes a Document.
  • PropertyProfile / VehicleProfile — 1:1 sidecars on NamedItem. Property is wizard-driven (4 questions); Vehicle is lookup-driven (DVLA + DVSA from the registration plate).
  • ActivityEvent — append-only log; is_undoable flag drives the timeline Undo affordance.

The classification pipeline

Upload                      Gmail Triage
   |                              |
   v                              v
Claude (live mode)           Heuristic pre-filter (sender domain, filename, household tokens)
   |                              |
   v                              v
ClassifierProposal      Thread + content-hash + recurring-email dedup
   |                              |
   v                              v
Document (filing_status=KEPT,    Claude (live mode, with email context + lazy-fetched bytes)
 reviewed_at=null)                |
                                  v
                            EmailScanFinding (status=pending) → Triage UI → Document

Both paths share the classifier service. Both produce a Document with the same shape.

The schedule + completeness panel

Lives in apps/profiles/services.py:

  • _schedule_for(named_item) returns the right schedule list for the kind (house or vehicle).
  • _profile_for(named_item) returns the right profile sidecar.
  • evaluate_schedule(named_item) matches the schedule against the household's filed documents, classifies each entry as have / missing / not_applicable, and returns counts + a percentage.

Schedule items are dataclasses with a condition callable that reads the profile's facts dict — that's how the MOT slot gets suppressed for vehicles under 3 years old, or how leasehold-only items get hidden for freehold houses.

Multi-tenant isolation

The HouseholdScopedManager reads the current request's household from a thread-local set by apps/common/middleware.py:CurrentHouseholdMiddleware. Every default Document.objects.all() is therefore implicitly scoped — a view that forgets to filter still won't leak.

Document.all_tenants is the explicit cross-household manager; reserved for management commands and audit code.

Contract tests in apps/common/tests/test_isolation.py and per-app test files exercise both correctness and the failure case (cross-household reads must return empty).

Interesting concrete details

  • Document.reviewed_at is stamped by both the per-block "Confirm all" and by bulk-Move-to-property — moving a doc is an assertion that it belongs there, no second-confirm step needed.
  • Topic page is a review queue. Property blocks only render if they have at least one unreviewed doc. Confirmed blocks vanish until new docs arrive. Browse-all-of-this-property's-docs lives on the property detail page.
  • Email scan dedup is four-keyed: by gmail_message_id (re-runs), by (thread_id, attachment_name) (forwarded threads), by attachment_sha1 (forwarded duplicates across threads), by (from_header, normalised_subject) (recurring marketing).
  • Boot-time migrations. entrypoint.sh runs python manage.py migrate against the volume-backed DB on every container boot. Fly's release_command runs on a separate machine without the volume — that path silently no-op'd against an empty DB until we noticed.
  • Token usage logging. The classifier logs every Claude call with input / output / cache-read / cache-creation tokens at INFO level so we can audit cost.