"""Shared Jinja2 environment for the hosting panel. Extracted out of ``app.py`` so both ``app.py`` and ``routes.py`` can import ``templates`` without triggering a circular import. We deliberately keep this module free of FastAPI / DB imports — it's pure Jinja2 + the shared icon helper. CSRF token handling ------------------- The CSRF token is resolved per-render via the request's ``state`` (``request.state.csrf_token``) — set by ``_set_csrf_cookie`` in the same handler — with a fallback to the incoming cookie. Templates call ``{{ csrf_token() }}`` through a Jinja global callable that always receives the active render context (containing the request). """ from __future__ import annotations from typing import Any from fastapi.templating import Jinja2Templates from jinja2 import pass_context from config import PANEL_CONFIG from icons import panel_icon from i18n import gettext, ngettext, available_languages, _detect_lang templates = Jinja2Templates(directory=str(PANEL_CONFIG.templates_dir)) @pass_context def _csrf_token(context) -> str: """Return the CSRF token for the current request. Resolution order: 1. ``request.state.csrf_token`` — pre-seeded by middleware before the route runs. Preferred on first render because ``request.cookies`` is empty at that point (the response cookie hasn't been written yet), so using it would produce an empty hidden input → Pydantic 422 "Field required". 2. ``request.cookies.get('csrf_token')`` — set by a previous request. The browser sends it back, the template uses it, and the response cookie matches. 3. Empty string — only happens if a template forgets to call ``_set_csrf_cookie`` before rendering. """ request = context.get("request") if hasattr(context, "get") else None if request is None: return "" state_token = getattr(getattr(request, "state", None), "csrf_token", None) if state_token: return state_token return request.cookies.get("csrf_token", "") @pass_context def _panel_flashes(context) -> list: """Return the list of queued toast notifications for this request. Populated by ``_flash_middleware`` (app.py) just before the route runs; the middleware also schedules the cookie for deletion on the response. Templates should call this and pipe the result into ``window.PANEL_FLASHES`` so ``panel_flash.js`` can render them. Returning ``[]`` (never ``None``) keeps the template expression ``panel_flashes() | tojson`` simple. """ request = context.get("request") if hasattr(context, "get") else None if request is None: return [] return getattr(getattr(request, "state", None), "flashes", []) or [] @pass_context def _panel_assets_v(context) -> str: """Per-deploy asset-bust token. Returns the current UNIX timestamp (truncated to seconds) so that ``{{ panel_assets_v() }}`` produces something like ``?v=1720123456``. We append this to ``/static/css/*.css`` and ``/static/js/*.js`` URLs to defeat the browser's "aggressive cache + same path = use cached copy" default after a deploy ships new animations / copy. Without the token, a user who landed on /dashboard before the deploy keeps serving the OLD CSS for the lifetime of their browser cache — and the new toast animations silently don't fire. Hot-reload note: the token changes on process start, so the user's very first request after each panel boot does a fresh GET for the asset. No DB hit, no rebuild, just a one-time CDN round-trip. """ import time as _time return str(int(_time.time())) @pass_context def _gettext_context(context, source: str, **kwargs: Any) -> str: """Jinja wrapper around ``gettext`` that reads the language from ``request.state.panel_lang`` (set by middleware) or falls back to the cookie / env default.""" request = context.get("request") if hasattr(context, "get") else None lang = None if request is not None: lang = getattr(getattr(request, "state", None), "panel_lang", None) if not lang: # Fall back to cookie / env if request is not None: cookie_lang = request.cookies.get("panel_lang") if cookie_lang: lang = cookie_lang if not lang: lang = _detect_lang() # Temporarily override the env-based detection for this call import i18n as _i18n old = _i18n._detect_lang _i18n._detect_lang = lambda: lang try: return gettext(source, **kwargs) finally: _i18n._detect_lang = old @pass_context def _ngettext_context(context, singular: str, plural: str, n: int, **kwargs: Any) -> str: """Jinja wrapper around ``ngettext``.""" request = context.get("request") if hasattr(context, "get") else None lang = None if request is not None: lang = getattr(getattr(request, "state", None), "panel_lang", None) if not lang: if request is not None: cookie_lang = request.cookies.get("panel_lang") if cookie_lang: lang = cookie_lang if not lang: lang = _detect_lang() import i18n as _i18n old = _i18n._detect_lang _i18n._detect_lang = lambda: lang try: return ngettext(singular, plural, n, **kwargs) finally: _i18n._detect_lang = old # Globals available to every template — keep this list small. templates.env.globals["panel_icon"] = panel_icon templates.env.globals["panel_title"] = PANEL_CONFIG.panel_title templates.env.globals["csrf_token"] = _csrf_token templates.env.globals["_"] = _gettext_context templates.env.globals["ngettext"] = _ngettext_context templates.env.globals["available_languages"] = available_languages templates.env.globals["_detect_lang"] = _detect_lang templates.env.globals["panel_flashes"] = _panel_flashes templates.env.globals["panel_assets_v"] = _panel_assets_v