Aller au contenu

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 for breadcrumbs context); 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

  1. 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.
  2. success_url after 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_url after delete redirects to the parent list page, not home (/). HTMX-only inline CRUD that returns a partial in form_valid still sets a usable get_success_url (the parent detail page) for the no-JS fallback — never return "/".
  3. 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.
  4. Sidebar active-state is server-rendered from request.resolver_match. Use namespace for whole-section highlighting ({% if request.resolver_match.namespace == 'practices' %}active{% endif %}) or url_name for single-route highlighting. Never hardcode the request path or set .active from JavaScript.
  5. Cross-practice access denial raises PermissionDenied (403). The shared path is FeatureRequiredMixin (in apps/core/mixins.py); per-object practice filtering scopes the view's get_queryset() by request.user.accessible_practices() (see the multi-tenant queryset rules in guidelines/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 by user_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.

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 on user_features (the context-processor-injected feature set from apps.core.context_processors.user_features).
  • Sub-labels inside a section (e.g. Reporting / Opérations / Clôture / Données GL under FINANCE) 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 orrequest.resolver_match.namespace == 'register' and request.resolver_match.url_name == 'register_list'.
  • Collapse state is per-user via localStorage key aletheia-sidebar-sections. The synchronous bootstrap script at the bottom of sidebar.html reads 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 per guidelines/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>. New user_features strings must be registered in apps/core/permissions.py:FEATURE_AREAS (DEBUG raises on unknown features).

Three slots, in DOM order:

  1. Sidebar toggle (#sidebar-toggle) — left-anchored, btn-action-secondary, hamburger icon. JS binds click to toggle #sidebar-wrapper visibility.
  2. {% block page_filters %} — centre-anchored. Pages that have a practice / period / dentist scope render their filter widgets here (see guidelines/ui/search-sort-filter.md for which mixin populates which widget). The filters render here even on detail pages when relevant (e.g. dashboards) — they are not list-only.
  3. 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 to set_language with next={{ 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.

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 JURIDIQUE covers entities/, 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_filter widget 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,3 overrides the cookie for the current request — useful for shareable deep links and HTMX requests. See MultiPracticeFilterMixin.get_selected_practices for 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 with practice_management access 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_groupdashboard_practicedashboard_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, set get_success_url to 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.path strings — re-rendering and routes change, JS lags. Use request.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 plain obj.name (record names aren't translated) or _("Modifier %(name)s") % {"name": obj.name} (per guidelines/i18n/translation-rules.md).
  • ❌ Hardcoding the menu in two places — sidebar items live in templates/components/sidebar.html only. Don't duplicate them into a per-page secondary nav.

Known deviations

  • success_url after create/update goes to list, not detail, in apps/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 in roadmap/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_valid returns the list partial, so success_url is 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 in roadmap/backlog/ux-navigation-breadcrumbs-coverage-2026-05.md.
  • Section-root crumb helpers (_capitalisation_crumb, _register_root_crumb) currently live only in apps/register/views.py. The pattern is sound; promote to a shared helper in apps/core/ once a second app needs it. Filed as roadmap/ideas/ux-section-root-crumb-helper.md.

When to break the rules

  • Dashboards (apps/dashboard/views.py:GroupDashboardView, PracticeDashboardView, DentistDashboardView, plus the Helios websites: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.html but 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; see contracts/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's data-bs-dismiss="modal" button. See guidelines/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.