Files
orion/docs/architecture/persona-template-consolidation.md
Samir Boulahtit f82dce30ca docs(architecture): persona template consolidation pattern + proposal
Document how admin/merchant/store templates share a single shared/ body
partial while keeping their three separate base templates. Covers:

- The wrapper/partial split and why the three base templates must stay
  separate (nav + permissions isolation).
- The scope contract: pass strings + booleans only, no macro objects,
  no `persona` enum.
- The backend mirror: services scope-agnostic, routes inject scope via
  auth deps, same Pydantic shape across personas.
- Legit exceptions and the heuristic for when to keep a template
  standalone (multi-tenant aggregators, persona-unique features).
- Forward reference to the TPL-016 architecture rule.

Adds both docs to mkdocs nav under Architecture and Proposals
sections.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:10:29 +02:00

7.0 KiB
Raw Blame History

Persona Template Consolidation

How to share Jinja templates and JS Alpine factories across the admin / merchant / store personas without losing per-frontend isolation.

The Problem

Most management features (CRUD over loyalty programs, products, orders, etc.) need to be reachable from three frontends:

  • admin — platform staff, cross-merchant view
  • merchant — merchant owner, cross-store view scoped to their merchant
  • store — store staff, scoped to one store

The naive approach maintains three separate Jinja templates per feature. Every change has to be made three times. They drift. Bug fixes only land in one. Eventually only one persona has the new column or the new modal.

The Pattern

Two layers:

1. Thin per-persona wrapper (one per frontend, MUST stay separate)

Each persona has its own template that:

  • {% extends %} the persona base (admin/base.html / merchant/base.html / store/base.html). This gives the right sidebar, header, auth widgets, and CSS scope. Never collapse this to a single base — the navigation/permissions framing differs by frontend, and merging risks accidentally rendering admin chrome to a store user.
  • Renders the page header, any persona-specific intro alerts, and the script tags.
  • Sets a handful of Jinja variables describing scope (e.g. cards_api_prefix='/merchants/loyalty', cards_base_url='/merchants/loyalty/cards', show_store_filter=true).
  • {% include %} a shared body partial.

Target size: 2060 LOC. If a wrapper grows much past that, something persona-unique is creeping in and you should extract it or accept it as a per-persona feature (see "Legit exceptions" below).

2. Shared body partial (one per feature, lives in <module>/templates/<module>/shared/)

Holds the actual CRUD body — filters, table, form, detail view. Reads the scope variables the wrapper set. Pure Jinja + Alpine bindings. No knowledge of which persona is rendering it.

Naming convention:

  • *-list.html — table + filter UI for a collection (e.g., cards-list.html)
  • *-form.html — create/edit form (e.g., program-form.html)
  • *-view.html — read-only detail page (e.g., card-detail-view.html)
  • *-stats.html — analytics panel (e.g., analytics-stats.html)

The Scope Contract

Wrappers pass scope to shared partials via simple Jinja include variables — strings and booleans only. No object params, no persona enum.

String variables typically carry URLs:

Variable Example Purpose
<feature>_api_prefix '/store/loyalty' API base for AJAX
<feature>_base_url '/merchants/loyalty/cards' URL prefix for detail links
cancel_url, back_url '/admin/loyalty/programs' Where actions navigate back to

Boolean variables toggle persona-specific UI:

Variable Convention
show_X Render an optional column / filter / button. Default off.
allow_Y Enable a permission-gated action. Default off.

The pattern lets the shared partial render the right shape without inspecting persona. Adding a new persona is "set new vars and include" — never "add another {% if persona == ... %} branch".

Canonical example: app/modules/loyalty/templates/loyalty/shared/cards-list.html. The header comment block documents every variable the partial expects. Wrappers (loyalty/admin/..., loyalty/merchant/cards.html, loyalty/store/cards.html) each set the four variables and include.

The Backend Mirror

The same pattern applies one layer down:

  • Services (app/modules/<module>/services/) accept simple ids and stay scope-agnostic. program_service.list_programs(db, skip, limit, ...) — no persona argument.
  • Routes (app/modules/<module>/routes/api/admin.py, .../merchant.py, .../store.py) inject scope via auth dependencies (get_current_admin_api, get_merchant_for_current_user, get_current_store_api) and pre-filter the query before calling the service.
  • Schemas (app/modules/<module>/schemas/) return the same Pydantic response across all three personas. Optional fields are fine for admin-only enrichment (e.g., merchant_name).

So a feature spans:

app/modules/<m>/services/<f>_service.py       # scope-agnostic
app/modules/<m>/routes/api/{admin,merchant,store}.py  # scope-injecting
app/modules/<m>/schemas/<f>.py                # same shape per persona
app/modules/<m>/templates/<m>/shared/<f>-list.html    # shared body
app/modules/<m>/templates/<m>/{admin,merchant,store}/<f>.html  # thin wrappers
app/modules/<m>/static/shared/js/<m>-<f>-list.js      # shared Alpine factory
app/modules/<m>/static/{admin,merchant,store}/js/<m>-<f>.js  # thin factory wrappers

Shared Alpine Factory Pattern

JS mirrors the template split. A shared factory in static/shared/js/ returns the Alpine component for the shared partial. Per-persona JS files are thin wrappers:

// app/modules/loyalty/static/merchant/js/loyalty-cards.js
function merchantLoyaltyCards() {
    return loyaltyCardsList({
        apiPrefix: '/merchants/loyalty',
        baseUrl: '/merchants/loyalty/cards',
        showStoreFilter: true,
        currentPage: 'cards',
    });
}

The factory always exposes any state the shared partial might read, even if a particular caller won't render that bit. For example, loyaltyCardDetailView always exposes txLabels: {} and pagination: {...} — the store wrapper populates them, admin/merchant get the empty defaults and the corresponding template blocks don't render.

Legit Exceptions (when to keep templates standalone)

The pattern shines for symmetric CRUD. It breaks down when a persona genuinely has a different shape of view, not just different data. Heuristic: if pushing the shape into the shared partial would force an {% if scope == 'admin' %} branch in every row, it's a different view — keep it standalone.

Real examples in the loyalty module:

  • loyalty/admin/programs.html — multi-merchant aggregator table with cross-cutting stats and a create-with-merchant-search modal. Merchant/store views show one program each, not a table of many. Standalone.
  • loyalty/admin/merchant-detail.html — admin-only tabbed view of one merchant's entire loyalty footprint. No merchant/store equivalent.
  • loyalty/store/terminal.html — hardware POS terminal UI. Store-only feature.
  • loyalty/store/enroll.html, storefront templates — customer-facing, different audience entirely.

Document the reason inline so future-you (or a contributor) doesn't try to "fix" it.

Enforcement: TPL-016

The architecture validator's TPL-016 rule (warning) flags any template under app/modules/<m>/templates/<m>/{admin,merchant,store}/*.html that exceeds 75 LOC without {% include %}-ing a */shared/* partial. This catches new persona templates that drift into copy-paste. Suppress with {# noqa: TPL-016 #} for the legit exceptions above.

The rule is intentionally a warning, not an error — it surfaces drift without breaking CI on first sight. Escalate to error once at least one non-loyalty module has been migrated and we're confident the pattern generalises.