Operator console (/ops/)¶
The staff-only back office for running Maud as a multi-tenant SaaS. Curated views live at /ops/ (every route staff_member_required); Django admin at /admin/ remains the raw CRUD and audit escape hatch, with all core models registered.
What's there¶
- Dashboard (
/ops/) — KPIs: households (+ suspended count), active users, documents (+ this week), pending findings, Claude spend this month; recent signups and recent ops actions. - Tenant directory (
/ops/households/) — search by household name, member username, or email. Columns: members, docs, plan, status, created. - Tenant detail (
/ops/households/<id>/) — members (with view-as buttons), document counts by source, pending findings, Gmail connections, classifier calls/tokens/cost (month + all time), plan card, lifecycle actions, the tenant's activity timeline, and this household's ops-audit history.
Plans + subscriptions (apps/billing)¶
Plan is the sellable catalog — seeded with Free (default), First Sweep (one-off), and Ongoing (yearly) from the roadmap's pricing item; prices are placeholders editable in admin. Subscription is one row per household pointing at a plan with a status (active / trialing / past_due / cancelled / complimentary); households without a row are implicitly on the default plan. set_plan() is the only write path, used by the ops console with notes + audit.
Limits are display-only. Plans carry gmail_triage / scan_window_days / max_documents / max_members, but limits_enforced is off everywhere — the console shows usage against limits without gating anything. Flipping enforcement on later is a data change at the (already-seamed) checks.
Payments are planned in, not implemented. The Stripe seam: Plan.stripe_price_id, Subscription.stripe_customer_id / stripe_subscription_id, the STRIPE_WEBHOOK_SECRET setting, and a webhook stub at /billing/webhooks/stripe/ that answers 501 until configured (so a mis-pointed Stripe dashboard fails loudly, never silently drops events). When Stripe lands: verify Stripe-Signature, then map checkout.session.completed → set_plan, customer.subscription.updated → status/period, customer.subscription.deleted → cancelled, invoice.payment_failed → past_due.
Usage metering (apps/classifier.ClassifierUsage)¶
Every live Claude call writes one row: household (nullable for unattributed commands), model, source (upload / email_in / gmail_scan), and the four token counters. No content or filenames are stored. estimated_cost_pence prices rows from the in-code MODEL_PRICES_GBP_PER_MTOK table — an ops approximation, not billing truth. Recording is best-effort and never blocks a classification; stub mode records nothing.
Lifecycle controls¶
- Suspend / reactivate — sets
Household.suspended_at. While suspended: members get a 403 "account suspended" page (logout stays reachable),run_scanreturns immediately, and inbound email payloads are stored but never classified. Staff are never locked out. This is an ops control, separate from plan limits. - Wipe contents — type-the-name confirmation, then resets the household to empty via the existing
wipe_householdcommand (keeps users, the household row, and Gmail connections). Full account deletion is a separate future GDPR flow.
View-as (read-only impersonation)¶
From a member row on the tenant detail page, staff can browse the app exactly as that member sees it: the OpsMiddleware overrides the resolved household (and the thread-local the scoped managers read) for the session. Every non-GET request outside /ops/ is refused with a 403 while viewing, so impersonation can never mutate tenant data. A red banner with a Stop button renders on every page; start and stop are audit-logged; staff users can't be targets; a forged session key on a non-staff account is ignored.
Audit trail (apps/ops.OpsAuditLog)¶
Append-only (admin delete disabled). Every privileged action writes a row: view-as start/stop, plan changes, suspensions, reactivations, wipes — actor, household, target user, detail, timestamp. Deliberately separate from the tenant-visible ActivityEvent timeline: support activity is the operator's business record, not part of a household's journal.
Admin styling¶
/admin/ is brand-styled (navy chrome, teal accent, light content) through templates/admin/base_site.html — purely CSS-variable overrides, no forked admin templates. Contrast was WCAG-AA-verified: buttons are navy-on-teal (white-on-teal fails), body links use a darker teal #0A6E62, and the theme is forced consistent across the admin's light/dark toggle. Tests in apps/ops/test_admin_branding.py pin the tokens so an upgrade can't silently revert it.
Getting staff access¶
fly ssh console -a maud -C "python manage.py shell -c \"from django.contrib.auth import get_user_model; u=get_user_model().objects.get(username='olly'); u.is_staff=True; u.is_superuser=True; u.save()\""
is_staff grants the console; is_superuser additionally grants full Django admin.