Navigation¶
Status: Implemented Last reviewed: 2026-05-01 Sources of truth:
templates/base.html(layout shell, four named blocks:breadcrumbs,page_filters,content,extra_js);templates/components/sidebar.html(collapsible sections + localStorage persistence);templates/components/navbar.html+templates/components/navbar_menu.html(top bar + profile dropdown);templates/components/breadcrumbs.html(renderer forbreadcrumbscontext);apps/core/mixins.py(MultiPracticeFilterMixin,FeatureRequiredMixin);apps/dashboard/views.py:DashboardRedirectView(post-login routing). Reference implementations:apps/entities/views.py:EntityCreateView/EntityUpdateView(success_url → detail);apps/register/views.py:_capitalisation_crumb / _register_root_crumb(breadcrumb helpers);apps/practices/templates/practices/practice_detail.html(breadcrumb override pattern).
Scope¶
Cross-page navigation: how the layout shell (sidebar + navbar + breadcrumbs + page filters) is composed in base.html; how breadcrumbs are populated and rendered; how a successful create / update / delete picks the next URL (success_url); how Cancel and Back buttons leave a form; how the active practice scope is surfaced and persisted; how the sidebar marks the active link.
Out of scope (covered elsewhere):
- List-page URL state (sort / filter / pagination encoded in the querystring) → guidelines/ui/search-sort-filter.md "URL/cookie persistence". The practice/period/dentist filters that live in {% block page_filters %} are part of that stack.
- In-form button styling, edit-mode safeguards → guidelines/ui/forms.md.
- Detail-page heading + Modifier/Supprimer/Retour action row → guidelines/ui/detail-pages.md.
- Toast vs banner vs hx-confirm after an action → guidelines/ux/feedback-and-confirmation.md.
- Cross-practice queryset filtering (the rule that backs the "deny cross-practice access" sentence below) → guidelines/security/multi-tenant-isolation.md (Placeholder).
Hard rules¶
- Every detail and form/edit page renders breadcrumbs. The view populates
context["breadcrumbs"] = [...]; the template overrides{% block breadcrumbs %}with{% include 'components/breadcrumbs.html' with breadcrumbs=breadcrumbs %}. List pages do not render breadcrumbs — the list IS the top of the trail. success_urlafter create / update redirects to the saved object's detail page (<app>:<model>_detail), not back to its list. Allows the "save and verify" flow.success_urlafter delete redirects to the parent list page, not home (/). HTMX-only inline CRUD that returns a partial inform_validstill sets a usableget_success_url(the parent detail page) for the no-JS fallback — neverreturn "/".- Cancel and Back are real
<a href>links, never<button type="button">. Destination: detail page if editing an existing record, list page if creating a new one.href="#"is a skeleton placeholder; replacing it with a real URL is part of copying the skeleton. - Sidebar active-state is server-rendered from
request.resolver_match. Usenamespacefor whole-section highlighting ({% if request.resolver_match.namespace == 'practices' %}active{% endif %}) orurl_namefor single-route highlighting. Never hardcode the request path or set.activefrom JavaScript. - Cross-practice access denial raises
PermissionDenied(403). The shared path isFeatureRequiredMixin(inapps/core/mixins.py); per-object practice filtering scopes the view'sget_queryset()byrequest.user.accessible_practices()(see the multi-tenant queryset rules inguidelines/security/multi-tenant-isolation.md). Never silently redirect on insufficient access; never return 404 to obscure existence (out of scope here, but invariant — the sidebar already gates entry points byuser_features).
Layout shell — templates/base.html¶
The shell is fixed and shared by every authenticated page. It exposes four named blocks; each page only fills what it needs.
{% block title %}{% endblock %} {# <head><title>, e.g. "Cabinets - Aletheia" #}
{% block extra_css %}{% endblock %} {# Page-scoped CSS files only #}
{% block breadcrumbs %}{% endblock %} {# Renders inside <main>, above messages — see Breadcrumbs #}
{% block page_filters %}{% endblock %} {# Renders inside <nav class="navbar"> — practice/period/dentist #}
{% block content %}{% endblock %} {# The page body #}
{% block extra_js %}{% endblock %} {# Page-scoped JS — tooltips, etc. #}
The shell's chrome (sidebar + navbar + messages) is rendered automatically when user.is_authenticated. Anonymous templates extend base.html too, but the chrome is hidden — login / password-reset flows render only {% block content %}.
The vertical order on screen is: sidebar (left) | navbar (top, with page_filters centred + profile dropdown right-aligned) | breadcrumbs | flash messages | content. Breadcrumbs and messages sit inside <main>, not the navbar.
Required structure for a navigated page¶
A typical detail/form page slots into the shell like this. The sidebar, navbar, messages, and CSRF middleware all come from base.html — the page only fills the four blocks it cares about.
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{{ practice.display_name }} - {% trans "Cabinet" %} - Aletheia{% endblock %}
{% block breadcrumbs %}
{% include 'components/breadcrumbs.html' with breadcrumbs=breadcrumbs %}
{% endblock %}
{% block content %}
{# heading + status row + cards per guidelines/ui/detail-pages.md #}
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => new bootstrap.Tooltip(el));
});
</script>
{% endblock %}
{% block page_filters %} is intentionally not filled here — detail pages don't usually have a practice / period / dentist scope, and the slot stays empty (the navbar centre is just empty space). For dashboards and lists that do have filters, fill it as guidelines/ui/search-sort-filter.md documents.
Sidebar — templates/components/sidebar.html¶
The single sidebar component is the canonical entry point to every feature. Pages do not render their own navigation.
- Sections are top-level groups (
TABLEAUX DE BORD,GESTION CABINETS,FINANCE,GESTION PATIENTS,GESTION DES DONNEES,NOMOS,DÉVELOPPEMENT,SECRÉTARIAT JURIDIQUE,SITES WEB,ADMINISTRATION). Section labels are uppercase, French, and gated onuser_features(the context-processor-injected feature set fromapps.core.context_processors.user_features). - Sub-labels inside a section (e.g.
Reporting/Opérations/Clôture/Données GLunderFINANCE) use<div class="sidebar-sub-label">and group thematically related links. Same gating rules. - Active link binding is server-rendered. The conventional patterns:
- Whole-namespace match:
{% if request.resolver_match.namespace == 'practices' %}active{% endif %}— covers list, detail, form, delete-confirm in one rule. - Single-route match:
{% if request.resolver_match.url_name == 'group' %}active{% endif %}— when sibling routes share a namespace but only one should highlight. - Multi-route within a namespace: combine with
or—request.resolver_match.namespace == 'register' and request.resolver_match.url_name == 'register_list'. - Collapse state is per-user via
localStoragekeyaletheia-sidebar-sections. The synchronous bootstrap script at the bottom ofsidebar.htmlreads it before paint to avoid section flicker. Section default = open; an explicit collapsed save sticks. Never collapse from server-side state. - No badges, no counts in sidebar items. Counts belong on the destination page (
{{ count }} {% trans "noun" %}under the heading perguidelines/ui/lists.md). - Adding a new section or item: follow the existing
<div class="sidebar-section">shell +{% if 'feature' in user_features %}gate +<i class="bi-..."></i> + <span class="ms-2">{% trans "Label" %}</span>. Newuser_featuresstrings must be registered inapps/core/permissions.py:FEATURE_AREAS(DEBUG raises on unknown features).
Navbar — templates/components/navbar.html + navbar_menu.html¶
Three slots, in DOM order:
- Sidebar toggle (
#sidebar-toggle) — left-anchored,btn-action-secondary, hamburger icon. JS bindsclickto toggle#sidebar-wrappervisibility. {% block page_filters %}— centre-anchored. Pages that have a practice / period / dentist scope render their filter widgets here (seeguidelines/ui/search-sort-filter.mdfor which mixin populates which widget). The filters render here even on detail pages when relevant (e.g. dashboards) — they are not list-only.- Profile dropdown (
navbar_menu.html) — right-anchored. Sections: Profile / Settings / Admin (staff only) → Language switcher (FR / EN / Source) → Theme switcher (brand + mode) → Logout. Logout is a<form method="post">so CSRF protection holds; the language switcher posts toset_languagewithnext={{ request.path }}so the user lands back where they were.
The navbar does not show "active practice" as a label. The practice scope lives in the page_filters widget — see "Active practice scope" below.
Breadcrumbs¶
Renderer¶
templates/components/breadcrumbs.html is a single-purpose renderer. It expects a breadcrumbs context variable that is a list of dicts with two keys:
[
{"label": "Cabinets", "url": "/practices/"}, # clickable
{"label": "Suffren Paris 7", "url": None}, # leaf — current page, no link
]
If the last item has url=None it renders as <li class="breadcrumb-item active" aria-current="page">; otherwise it renders as a link. If breadcrumbs is empty or absent, the renderer outputs nothing.
Where breadcrumbs come from¶
The view populates context["breadcrumbs"]. The template overrides {% block breadcrumbs %} with the include. Two patterns:
# Inline list (most views)
ctx["breadcrumbs"] = [
{"label": _("Cabinets"), "url": reverse("practices:practice_list")},
{"label": practice.display_name, "url": None},
]
# Helpers for repeated section roots (apps/register/views.py — _capitalisation_crumb / _register_root_crumb)
ctx["breadcrumbs"] = [
_capitalisation_crumb(),
_register_root_crumb(),
{"label": entity.code, "url": None},
]
{% block breadcrumbs %}
{% include 'components/breadcrumbs.html' with breadcrumbs=breadcrumbs %}
{% endblock %}
Trail conventions¶
- Leaf is non-clickable (
url=None). Always. - First item is a section root (e.g.
Cabinets,Entités,Registre). For nested apps with a top-level section (SECRÉTARIAT JURIDIQUEcoversentities/,register/), use a non-clickable section-header crumb (url=None) followed by the clickable list root — see_capitalisation_crumb(). - Form pages chain through their parent. Example: edit a holder →
Secrétariat juridique > Registre > Détenteurs > Jean Dupont > Modifier. The penultimate link points at the detail page so the user can abandon the edit cleanly. - Labels are French and
_()-wrapped for static segments; raw strings for record names ({"label": entity.code},{"label": practice.display_name}). - List pages do not render breadcrumbs. The sidebar's active state already locates the user; a single-segment breadcrumb is noise.
- No truncation today. Long labels wrap; long trails wrap to multiple lines. If a record's display name is unbounded (rare), the view should slice it before adding the crumb — but no global ellipsis rule.
Redirect after action — success_url¶
| Action | Convention | Example |
|---|---|---|
| Create succeeds | Detail of new object | reverse("entities:entity_detail", kwargs={"code": self.object.code}) |
| Update succeeds | Detail of saved object | reverse("crm:prospect_detail", kwargs={"pk": self.object.pk}) |
| Delete succeeds | Parent list page | success_url = reverse_lazy("crm:prospect_list") |
Inline child created/updated/deleted (HTMX, partial returned in form_valid) |
Parent detail page (no-JS fallback) | reverse("dentists:dentist_detail", args=[self.kwargs["dentist_pk"]]) |
reverse_lazy for class-attribute success_url (evaluated at import time); reverse inside get_success_url (evaluated per request, can use self.object). Both work; the lazy variant is mandatory at class scope.
class HolderCreateView(FeatureRequiredMixin, AuditMixin, CreateView):
def get_success_url(self):
return reverse("register:holder_detail", kwargs={"pk": self.object.pk})
class ProspectDeleteView(CrmNavMixin, FeatureRequiredMixin, DeleteView):
success_url = reverse_lazy("crm:prospect_list")
The "redirect to detail after save" convention is the direction of travel — newer apps (entities, register, crm, websites, budgets) follow it; older modules (patients, practices, dentists, accounts user CRUD, imports) still redirect to list. Tracked as a Known deviation; new code goes to detail.
In-form Cancel / Back¶
The skeleton's Cancel button is <a href="#"> — that # is a placeholder that must be replaced when copying the skeleton. The replacement rules:
- Editing an existing record: Cancel goes to that record's detail page (
reverse("...:..._detail", args=[obj.pk])). - Creating a new record: Cancel goes to the parent list page.
- Inline HTMX child form (skill, training, hours, contact, activity): Cancel is a
<button type="button">that issues an HTMX swap back to the list partial. The button is intentional here — it's not navigation, it's "close this widget". This is the only place a Cancel<button>is appropriate.
A detail page's "Retour" button is a real <a href> to the parent list — never history.back() and never a button-as-link. Right-click "open in new tab" must work.
Active practice scope¶
Aletheia is multi-tenant. The user's practice scope is multi-select (a user can hold any subset of their accessible practices in scope at a time), not a single sticky "active practice".
- Where the user picks it: the
practice_filterwidget rendered in{% block page_filters %}on every list and dashboard page that has a practice scope. The widget is multi-select with explicit "Tous les cabinets" / "Aucun cabinet" affordances. - How it persists: a cookie (
aletheia_practices) holds the selected set so the user keeps the same scope across pages. URL?practices=1,2,3overrides the cookie for the current request — useful for shareable deep links and HTMX requests. SeeMultiPracticeFilterMixin.get_selected_practicesfor the precedence (URL > cookie > all-user-practices default). - What controls visibility: sidebar entries are gated on
user_features(a coarse-grained feature set per user). The practice filter is a scope (which records to show), not a permission — a user withpractice_managementaccess still sees the filter and chooses among their practices. - Login routing:
apps/dashboard/views.py:DashboardRedirectView(mounted at/) picks the first dashboard the user has access to (dashboard_group→dashboard_practice→dashboard_dentist). No "pick your practice" intermediate screen — the filter handles that on every page.
Per-tab scope is not supported — the cookie is browser-scoped, so two tabs share state. If a user wants to compare two scopes side-by-side, they use the URL override (?practices=1 in tab A, ?practices=2 in tab B).
Anti-patterns¶
- ❌
success_url = "/"— opaque destination; on no-JS / non-HTMX it dumps the user at the dashboard root with no signal of what they just did. If the view is HTMX-only, setget_success_urlto the parent detail page. - ❌ List page with its own
{% block breadcrumbs %}— the sidebar already locates the user; a single-segment breadcrumb is noise. - ❌ Detail or form page with no breadcrumbs — the user can't see where they came from after a deep link.
- ❌ Cancel as
<button type="button">on a non-HTMX form — breaks no-JS, breaks middle-click "open in new tab". - ❌ Sidebar active-state set from JS or via hardcoded
request.pathstrings — re-rendering and routes change, JS lags. Userequest.resolver_match.namespace/url_name. - ❌ Breadcrumb leaf with a
url— the user is already on that page; clicking it is a no-op that re-renders. - ❌
_(f"Modifier {obj.name}")for a breadcrumb label or page title — interpolates before_()resolves; never translates. Use plainobj.name(record names aren't translated) or_("Modifier %(name)s") % {"name": obj.name}(perguidelines/i18n/translation-rules.md). - ❌ Hardcoding the menu in two places — sidebar items live in
templates/components/sidebar.htmlonly. Don't duplicate them into a per-page secondary nav.
Known deviations¶
success_urlafter create/update goes to list, not detail, inapps/practices/views.py(PracticeCreateView,PracticeUpdateView),apps/dentists/views.py(DentistCreateView,DentistUpdateView),apps/patients/views.py(PatientCreateView,PatientUpdateView),apps/accounts/views.py(UserCreateView,UserUpdateView). Pre-dates the detail-redirect convention. Tracked inroadmap/backlog/ux-navigation-success-url-drift-2026-05.md.success_url = "/"(placeholder fallback) in 12 dentist sub-CRUD views (DentistSkillCreate/Update/DeleteView,DentistTraining*,DentistWorkSchedule*,DentistRevenueTarget*—apps/dentists/views.py:492-743). All are HTMX-inline;form_validreturns the list partial, sosuccess_urlis never followed today. Replace with the parent dentist or contract detail page so the no-JS fallback degrades gracefully. Tracked as part of the same backlog item.- Detail and form pages without breadcrumbs: ~14/26 detail templates and ~15/26 form templates still omit
{% block breadcrumbs %}. Concentration:apps/finance/,apps/finance_workflow/,apps/dataquality/,apps/sync/,apps/pennylane_sync/,apps/nomos_*/. Tracked inroadmap/backlog/ux-navigation-breadcrumbs-coverage-2026-05.md. - Section-root crumb helpers (
_capitalisation_crumb,_register_root_crumb) currently live only inapps/register/views.py. The pattern is sound; promote to a shared helper inapps/core/once a second app needs it. Filed asroadmap/ideas/ux-section-root-crumb-helper.md.
When to break the rules¶
- Dashboards (
apps/dashboard/views.py:GroupDashboardView,PracticeDashboardView,DentistDashboardView, plus the Helioswebsites:dashboard,crm:dashboard,pennylane_sync:dashboard,collection:dashboard, etc.) skip breadcrumbs — they're top-level destinations of the sidebar, not nested records. - Login / logout / password-reset templates extend
base.htmlbut render only{% block content %}— no sidebar, no navbar, no breadcrumbs. The shell's{% if user.is_authenticated %}gate handles this automatically. - API endpoints (
apps/websites/urls_api.py, the Helios contract) don't render templates at all. Navigation rules don't apply; seecontracts/api_contract.md. - Modal-internal navigation (the close-period reopening flow in
apps/finance/templates/finance/close_workflow.html) stays inside the modal — no breadcrumbs, the cancel is the modal'sdata-bs-dismiss="modal"button. Seeguidelines/ux/feedback-and-confirmation.md"When a modal is justified". - Confirmation views (
*_confirm_delete.html) sometimes carry breadcrumbs (apps/register/templates/register/assembly_confirm_delete.html), sometimes don't. Either is acceptable — the page already has a clear "Annuler" link back to the detail; the breadcrumb is a nice-to-have, not a hard requirement.