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.
Table of contents
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.
Menu entries
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
- Access control, for the roles and policies the menu follows
- Template system, for the hook slots and the other globals
- Theme system, for the public shell and its main nav