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
householdFK.NamedItem.kindishouseorvehicle. - 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_undoableflag 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 ashave/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_atis 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), byattachment_sha1(forwarded duplicates across threads), by(from_header, normalised_subject)(recurring marketing). - Boot-time migrations.
entrypoint.shrunspython manage.py migrateagainst the volume-backed DB on every container boot. Fly'srelease_commandruns 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.