Menus and shells

A product renders through two shells, the signed-in application and the public website. The framework gives them what they need without writing any markup. Features declare menu entries in a registry and the shell asks on each request which ones the account sees, shell() names the layout a page extends, each asset says which shell loads it, and every request has a nonce for its Content-Security-Policy.

How menu entries go from the features to the shell on each request AT STARTUP EACH REQUEST TO THE SHELL Features, in init_feature register_menu_item(...) register_user_menu_item(...) register_menu_section(...) The product's pyproject [tool.splent.layout.menu] hide and order, by key not hidden by the product the section admits the account a URL from the endpoint roles, and the endpoint's policy the current entry, then badges Plain data label, url, icon active, aria_current badge, section from menu_sections() and user_menu(), which the theme turns into markup shell('app') names the layout a page extends, and a product may point it at another file. get_assets(kind, shell='app') gives each shell its own assets. csp_nonce() gives each request its own nonce, for the tags a template owns.
Features declare entries once. On every request the registry keeps what the account may open, marks the current entry and hands plain data to the shell, which owns all of the markup.

Table of contents

  1. Example
  2. Menu entries
    1. Who sees an entry
    2. The current entry
    3. Badges and labels
    4. Sections
    5. The product’s hide and order
  3. Layouts by shell
  4. Assets per shell
  5. The CSP nonce
  6. Options
    1. register_menu_item and register_user_menu_item
    2. register_menu_section
    3. Reading the menus
    4. Jinja globals
    5. Settings
  7. Special cases
  8. See also

Example

A feature declares its entries in init_feature.

from flask_babel import lazy_gettext as _l

from splent_framework.nav.menu_registry import (
    register_menu_item,
    register_user_menu_item,
)
from splent_framework.services.service_locator import service_proxy

dataset_service = service_proxy("DatasetService")


def _pending_reviews():
    return dataset_service.count_pending()


def init_feature(app):
    register_menu_item(
        "explore", _l("Explore"), endpoint="explore.index", icon="search"
    )
    # dataset.admin_review is gated with role_required("admin", "staff"),
    # and the entry takes those roles from the screen.
    register_menu_item(
        "dataset_review",
        _l("Review"),
        endpoint="dataset.admin_review",
        icon="check-circle",
        section="admin",
        badge=_pending_reviews,
    )
    register_user_menu_item(
        "profile", _l("Profile"), endpoint="profile.edit", icon="user"
    )

The application shell draws what the account sees. This excerpt of base_template.html, the layout shell('app') names, fills its sidebar. The shell is a whole page and extends nothing, and the pages of the application extend it.

<nav id="sidebar" class="sidebar">
  <ul class="sidebar-nav">
    {% for section in menu_sections() %}
      <li class="sidebar-header">{{ section.label }}</li>
      {% for item in section.entries %}
        <li class="sidebar-item{{ ' active' if item.active else '' }}">
          <a class="sidebar-link" href="{{ item.url }}"
             {% if item.aria_current %}aria-current="{{ item.aria_current }}"{% endif %}>
            {% if item.icon %}<i data-feather="{{ item.icon }}"></i>{% endif %}
            <span>{{ item.label }}</span>
            {% if item.badge %}<span class="badge">{{ item.badge }}</span>{% endif %}
          </a>
        </li>
      {% endfor %}
    {% endfor %}
  </ul>
</nav>

<main class="content">
  {% block content %}{% endblock %}
</main>

A page fills content and gets the sidebar with it.

{% extends shell('app') %}

{% block content %}
  <h1>Datasets</h1>
{% endblock %}

The product hides and reorders entries by key, without touching any feature.

[tool.splent.layout.menu]
hide = ["explore"]
order = { dataset_review = 10, profile = 5 }

The framework only resolves the menu. Drawing it is the shell’s job, and a base_template.html that never calls menu_sections() shows none of these entries. A feature that needs its entry in such a shell keeps its layout.authenticated_sidebar hook as well.


Who sees an entry

Each entry belongs to a section, and the section says who it is for. The roles narrow that, and an access policy covering the entry’s endpoint decides before the roles, exactly as it does for the screen.

The entry has Shown to
No roles, and its screen is gated by role_required The accounts has_role(*the screen's roles, endpoint=endpoint) lets in, the answer the screen gives
No roles, and its screen is not Everyone the section is for, unless a policy covering the endpoint refuses the account
roles The accounts has_role(*roles, endpoint=endpoint) lets in. These roles replace the screen’s
An href and no endpoint Everyone the section is for, or the accounts with one of its roles. No policy is asked, because no endpoint is there for one to cover

The menu never offers a door that answers 403, and a feature does not repeat the roles of its screens. role_required leaves the roles it asks for on the view, and required_roles(view) in splent_framework.decorators.decorators reads them. Name roles only for an entry that must be narrower than its screen. Policies are only asked about a signed-in, active account, and never about an endpoint the product does not have, because the entry’s URL is built first.

The current entry

At most one entry of the sidebar is current, and one of the user menu. An entry matches when the request’s endpoint starts with its endpoint, and the longest match of the whole menu wins, so admin.users beats an Admin entry that matches every admin. screen. Among equal matches the first one drawn wins. match sets the prefix, or several, when the endpoint is not the right one.

A whole blueprint is its name and a dot. match="admin." covers every screen of the admin blueprint, while match="admin" would also cover admin_tools.index, so a match without a dot logs a warning once.

register_menu_item(
    "admin",
    _l("Admin"),
    endpoint="admin.dashboard",
    section="admin",
    match="admin.",
)

The current entry has active set to True and aria_current set to "page".

Badges and labels

A badge is a value next to the label, usually a count. A zero-argument callable is asked on render, only for an entry the account sees, and once per request however many times the shell reads the menu. None, 0 and "" show nothing, and a callable that raises shows nothing and logs a warning.

A label is translated when the menu is resolved, so it follows the language switcher. Write it with lazy_gettext (or _l), which feature:translate --extract collects into the feature’s catalog. A plain string is passed through gettext too, and a zero-argument callable is called.

Sections

Key Heading Order For
main Menu 100 Signed-in accounts
admin Administration 200 Signed-in accounts
developer Developer 300 Signed-in accounts
account Account 400 Signed-in accounts
anonymous Get started 500 Visitors who are not signed in

A feature adds a section with register_menu_section(key, label, order). audience="everyone" makes one for everybody, signed in or not.

from splent_framework.nav.menu_registry import register_menu_section

register_menu_section("research", _l("Research"), 150)

The headings ship in the framework’s catalog, in Spanish too.

The product’s hide and order

[tool.splent.layout.menu] in the product’s pyproject.toml takes two options, both by entry key and both for the sidebar and the user menu.

Option Value Effect
hide A list of keys Those entries are never shown, and their badges are never asked
order A table of key to whole number That number replaces the order the feature gave

The framework reads the table when the app starts and keeps it in app.config["SPLENT_LAYOUT_MENU"]. unknown_menu_keys(app) lists the keys it names that no entry has, once every feature has loaded, which is how a check warns about a typo or a feature that left the product.


Layouts by shell

shell(name) returns the template a page extends, so a page names its shell instead of a file.

{% extends shell('app') %}
Name Resolves to Unless
app base_template.html SPLENT_SHELL_APP names another template
public public_base.html SPLENT_SHELL_PUBLIC names another template
login The application shell AUTH_LOGIN_LAYOUT names a template

The settings are read from the app config first and the environment second, so a product sets them in [tool.splent.config].

SPLENT_SHELL_APP and SPLENT_SHELL_PUBLIC only move the pages that extend shell(...). A page that names a file, {% extends "base_template.html" %}, stays in that file.

Page Extends Follows
A page written with shell('app') or shell('public') The shell’s template SPLENT_SHELL_APP or SPLENT_SHELL_PUBLIC
The framework’s 403 page shell('app') SPLENT_SHELL_APP
splent_feature_auth’s login and splent_feature_signup’s sign-up config.AUTH_LOGIN_LAYOUT or "base_template.html" AUTH_LOGIN_LAYOUT only

shell('login') agrees with the sign-in pages while SPLENT_SHELL_APP is empty. With it set and AUTH_LOGIN_LAYOUT empty, shell('login') names the new layout and the sign-in pages stay in base_template.html, so a product that moves its application shell sets AUTH_LOGIN_LAYOUT too.


Assets per shell

register_asset takes the shell that loads the file.

from splent_framework.assets.asset_registry import register_asset

register_asset(
    "css", "skin_lab.assets", order=200, shell="app",
    subfolder="css", filename="admin_lab.css",
)
shell Loaded by
"public", the default The public shell
"app" The application shell
"both" Both shells

A shell asks for its own with get_assets("css", shell="app"), which returns the assets registered for that shell and those registered for "both". get_assets("css") is the public shell’s answer, which includes every asset registered without a shell. Registering an asset again for the other shell makes it serve both. shell is keyword only in both functions and never reaches url_for.


The CSP nonce

csp_nonce() returns the nonce of the request being served, 128 random bits in base64url. Every call of one request returns the same value and the next request gets a new one. A template puts it on the tags it owns.

<script nonce="{{ csp_nonce() }}" src="{{ href }}"></script>

The Content-Security-Policy header does not carry the nonce. SPLENT_CONTENT_SECURITY_POLICY is sent exactly as written (see security headers), so the attribute changes nothing today, and a tag that carries it is ready for a policy that asks for it.

Once a directive carries a nonce, browsers ignore 'unsafe-inline' in it, and every inline script or style without the nonce stops running. So the nonce will only ever be added to a policy that asks for it, never to one that relies on 'unsafe-inline'.


Options

register_menu_item and register_user_menu_item

register_user_menu_item takes the same arguments and puts the entry in the user menu, with account as its default section.

Argument Default Meaning
key required Stable identifier. The product hides and orders by it, and registering it again replaces the entry
label required The text, a lazy string, a plain string or a zero-argument callable
endpoint None The endpoint it opens. Give this or href
href None A URL, used as written
icon None A feather icon name
section "main" The section it belongs to
order 100 Lower first within the section, ties by key
roles None The roles that see it, in place of the roles of its screen’s role_required. One role may be a string
badge None A value or a zero-argument callable
match the endpoint Keyword only. The endpoint prefix, or a tuple of prefixes, that make it current. A whole blueprint ends with a dot, "admin."

register_menu_section

Argument Default Meaning
key required The name entries use in section
label required The heading, translated like an entry’s label
order 100 Lower first
audience "signed_in" Keyword only. "signed_in", "anonymous" or "everyone"

Reading the menus

Function Returns
menu(section="main") The entries of one sidebar section the account sees
menu_sections() [{"key", "label", "entries"}], the sidebar’s sections with something to show, in order. A template reads section.entries, because section.items is the dict’s method
user_menu() Every user menu entry the account sees, sections in order
user_menu_sections() The user menu grouped by section, for a dropdown with dividers
unknown_menu_keys(app=None, layout=None) The keys of the product’s table that no entry has
parse_layout_menu(raw) The table checked and normalized, or ValueError
clear_menus() Empties both menus and restores the default sections, for tests
required_roles(view) The roles role_required asks for on view, only those both accept when two are stacked, () for none, or None when it does not gate the view. In splent_framework.decorators.decorators

Each entry is a dict.

Key Holds
key The entry’s key
label The translated text
url The endpoint’s URL or the href
icon The icon name or None
active True on the current entry
aria_current "page" on the current entry, else None
badge The badge value or None
section The section’s key

Jinja globals

menu, menu_sections, user_menu, user_menu_sections, shell and csp_nonce are globals of every template, next to the ones in the template system.

Settings

Setting Where Default Effect
SPLENT_SHELL_APP [tool.splent.config] or the environment empty The template shell('app') resolves to, which the framework’s 403 page extends too
SPLENT_SHELL_PUBLIC [tool.splent.config] or the environment empty The template shell('public') resolves to
AUTH_LOGIN_LAYOUT [tool.splent.config] or the environment empty The template of the sign-in pages, shell('login')
[tool.splent.layout.menu] The product’s pyproject.toml absent hide and order, read when the app starts

Special cases

Case Behaviour
The endpoint is not in this product The entry is left out, as when its feature is not installed, and no access policy is asked about it
The endpoint needs URL arguments The entry is left out. Give an href instead
The same key registered twice The later entry replaces the earlier one, which is how a refiner changes its base’s
An entry registered in a module body It is lost when the next app is built, because the menus are cleared before the features load. Register from init_feature
Two apps built in one process, as in a test session The registries are process-wide, so the last app built wins and both serve its entries
An entry names a section nobody registered It is left out, with one warning in the log
roles on an entry of the anonymous section ValueError, because a visitor who is not signed in has no role
An entry of a section for visitors, anonymous or one registered with audience="anonymous", with no roles and a screen gated by role_required Never shown, because a visitor has no role, and one warning per key in the log. Point it at a screen visitors can open, or move it to a section for signed-in accounts
roles=(), or a screen gated by role_required() with no roles Nobody sees it unless an access policy covering the endpoint lets the account in
A screen gated by two stacked role_required The entry takes the roles both accept, the only ones the screen lets in
A screen gated by a decorator that drops the view’s attributes The entry no longer knows the screen’s roles and is shown as an entry without roles. Write the decorator with functools.wraps, or name roles
A match without a dot One warning in the log, because it also matches every blueprint whose name starts with it
Both endpoint and href, or neither ValueError
An inactive account Sees no entry with roles and no entry whose screen is gated by role_required, only the rest
Outside a request, as in an email Nobody is signed in and nothing is current. An endpoint entry is left out unless SERVER_NAME is set, and outside any app context the menus are empty
A malformed [tool.splent.layout.menu] The app does not start, and the error names the option and the file
A key in hide or order that no entry has Ignored when drawing, and listed by unknown_menu_keys
The table changed Read when the app starts, so restart it
register_asset(..., shell="admin") ValueError when the feature loads
get_assets(kind, shell="both") ValueError. A page renders in one shell, so it asks for "public" or "app"
The same asset registered for each shell One asset that serves both
shell('admin') ValueError naming the shells
The template a shell setting names extends that same shell It extends itself, and the page fails with RecursionError. That template extends the file, for example {% extends "base_template.html" %}
csp_nonce() outside any app context RuntimeError, as for flask.g
Several requests inside one app context, as in tests Each request still gets its own nonce and resolves its own menu

See also


Back to top

splent. Distributed by an LGPL license v3. Contact us: drorganvidez@us.es