Static Pages Development Notes
Models
StaticPageNav stores menu categories. use_category_url shortcuts the category click to a custom url. nav_element defines ordering.
StaticPage is the CKEditor-backed page content. members_only gates access, and slug is unique (max 50 chars). update() stamps modified_time.
StaticUrl represents dropdown entries linked to a StaticPageNav. logged_in_only hides links from anonymous users, and dropdown_element controls ordering for the admin ordering widget. A parent self-relation lets a StaticUrl carry nested children.
- Nested navigation is rendered by
templates/common/core/header_nav_items.html for every association. The navigation context processor prefetches only children that have a url, so a parent whose children are all URL-less (or hidden by logged_in_only/archive rules) renders as a plain link rather than an empty toggle; a category whose top-level entries would all render nothing is dropped too. The anonymous navigation cache key carries a schema segment (v2); bump it whenever the cached payload changes shape, so a deploy never serves entries written by the previous code. Each link, parent or child, goes through templates/common/core/header_nav_link.html, which swaps the login link for the logout form wherever it appears; with submenu=True the same control is also the submenu toggle (a plain button for a URL-less parent), and a signed-in user keeps a login-url parent’s children under a Logga ut toggle.
- The layout (hidden until hover or keyboard focus, always expanded in the collapsed mobile navbar) lives in
static/common/core/css/header-submenu.css, which core/base.html links right after core/css/header.css. It also dims child links with opacity, because every header sheet pins .dropdown-item colour with !important; association sheets add nothing for submenus.
static/common/core/js/nav-submenus.js (deferred, linked from core/base.html) reads the data-nav-submenu* hooks. It keeps each toggle’s aria-expanded in step with whether its aria-controls list is displayed, and makes a URL-less parent (data-nav-submenu-button) open on click or tap by setting data-open on its item. That click handler stops propagation, because Bootstrap’s document-level clearMenus would otherwise close the whole dropdown. hide.bs.dropdown clears data-open, and so does the pointer and focus both leaving the item, because a dropdown opened by CSS hover never fires that event. A parent with a url still navigates on click, so its children need hover or focus on wide screens.
- The child list is a plain
ul.dropdown-submenu-list, not a Bootstrap .dropdown-menu. Bootstrap 5.1’s dropdown keyboard handler attaches to the closest .dropdown-menu around the focused item and throws on a nested one, which stopped arrow-key navigation and Escape inside a submenu. date/tests.py (test_every_association_header_renders_nav_submenus) guards both.
Views & Routing
StaticPageView (staticpages/views.py) is the only view. It loads the page by slug, checks members_only, and either renders staticpages/staticpage.html or redirects unauthenticated users to /members/login.
- Policy pages (
equality_plan_view, registration_terms_view) 404 unless the matching capability is enabled: EQUALITY_PLAN_ENABLED / REGISTRATION_TERMS_ENABLED (both set for date in its settings module; defaults are False in core/settings/common.py).
- Navigation menus are built in templates using
StaticPageNav + StaticUrl; the merged staticpages.context_processors.navigation injects categories and urls into every template in a single load (3 queries). Anonymous navigation is cached per project/language/archive-mode and invalidated on StaticUrl/StaticPageNav saves or deletes; development uses the dummy cache, so caching is off there. The legacy get_categories/get_urls names remain as thin wrappers.
- Language-aware internal links should go through the
localized_url template filter (staticpages/templatetags/localized_urls.py) so stored URLs keep the current locale prefix when language features are enabled.
- Routes are wrapped by the shared localized URL builder in
core/urls/common.py, so static pages can live under language prefixes without duplicating route declarations.
Rendering CKEditor Content
- CKEditor 5 stores image alignment and resizing as classes (
.image.image-style-align-left, .image.image_resized, figure.table), so rendering its HTML needs the rules that act on those classes. They live in static/common/core/css/ck-content.css, linked from templates/common/staticpages/staticpage.html.
- The element wrapping the content must carry the
ck-content class, otherwise none of the stylesheet matches. static/common/staticpages/css/staticpage.css is the page’s own (currently empty) hook and is not where these rules belong.
ck-content.css is a hand-kept subset of the .ck-content section of the bundled django_ckeditor_5/dist/styles.css. The bundled file is 230 kB, includes the editor chrome, and sets the font, size and line height of .ck-content, which would replace the site typography on every content page. Re-check the subset when django-ckeditor-5 is upgraded; core/tests/test_ckeditor.py parses the stylesheet and fails if one of the image rules loses the declaration that places the image.
- An association template that overrides
extra_head must call ``. templates/pulterit/staticpages/staticpage.html used not to, which dropped both stylesheets on pulterit pages.
- The association
base.css files cap content images with .content img { width: auto !important; height: auto !important; max-height: 35vh }. ck-content.css releases all three for an image inside .image.image_resized, because the width the editor stored on the figure would otherwise be ignored and the aspect ratio squashed once the width is forced. The cap still applies to every other content image.
- A resized inline image is the one case left broken. CKEditor stores that width on the
img element itself, and .content img { width: auto !important } outranks an inline style, so the stored width is ignored. No rule in ck-content.css can bring it back, because CSS cannot read the inline value again once an important rule has won. Dropping the two !important flags from the .content img block in every association base.css and in static/common/lucia/css/style.css fixes it; measured on layout that changes nothing except the inline case, but the selector reaches every rich text page on every site, so it wants its own change. Only pasted or source edited content can hold an inline image, because the toolbar does not offer the inline image style.
news/article.html, ctf/*, lucia/* and events/detail.html render CKEditor HTML through a plain .content wrapper, so alignment, resizing and tables there still fall back to the unaligned defaults. They each need the ck-content class and a stylesheet link to render like the editor does. events/detail.html also wraps the content in a <p>, which makes the parser hoist figure and table out of the paragraph.
Admin/Ordering
StaticPageNavAdmin allows inline management of StaticUrl rows using admin-ordering. Dragging rows updates the dropdown_element; category and dropdown URLs render quick open links for checking destinations.
- The URL fields use
NavTargetWidget (staticpages/widgets.py), a free-text input with a <datalist> of the site’s own static pages, a few common section routes, and URLs already used in the menu. It stays a normal text field, so external addresses and hand-written paths are still allowed. validate_nav_target in models.py rejects bare relative paths such as pages/om-oss/ and invalid schemes; a category URL is only required and validated when use_category_url is on, because the value is ignored otherwise.
StaticPageAdmin shows only the background image field that matches settings.USE_S3 and labels it as local or public S3. When a stale image from the other storage is still set, both fields stay visible so it can be cleared. Both fields use SafeAdminImageWidget, so a stored file with an unresolvable URL does not break the change form.
- The page change form adds an
I menyn read-only row that lists the StaticUrl rows pointing at the page (linking to their categories) or says that the page is not linked yet.
- The shared header matches links to categories by
category_id, not by the translated category name, so two categories with the same name cannot swap each other’s links.
StaticPageAdmin lists pages with members_only badge, per-language translation coverage, and public-page links for quick auditing. The slug field is prepopulated from the title on new pages. When language features are enabled, the local language tabs show one translated title/content version at a time without relying on an external JavaScript CDN. In the stock admin the public-page link is placed at the top of the first fieldset; Unfold keeps its own “View on site” object action.
Extending the App
- Distinguish between external URLs and internal paths when adding menu links. External URLs should remain absolute; internal paths should stay relative so
localized_url can rewrite them.
- If you add more visibility rules, update both the model fields and the shared navigation template logic together.
- To add versioning, introduce a
StaticPageRevision model storing snapshots when update() runs.
- To support scheduling, add
publish_at/unpublish_at fields and filter in the view before rendering.
- Consider caching navigation structures if menu lookups become expensive; currently everything hits the database on each request.