Skip to content

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.completedset_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_scan returns 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_household command (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.