bot_host / templates_setup.py
ItsBounvy's picture
deploy: english UI + admin creds + dataset wiring
87342f3 verified
Raw History Blame Contribute Delete
5.98 kB
"""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