Coverage for src/local_deep_research/web/template_config.py: 91%
71 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
1"""
2Shared Jinja2 templates instance.
4This module exists to break the circular import between fastapi_app.py
5and router modules. Routers import templates from here instead of
6from fastapi_app.py.
7"""
9from importlib import resources as importlib_resources
10from pathlib import Path
11from typing import Any
13from fastapi.templating import Jinja2Templates
14from starlette.requests import Request
15from starlette.responses import Response
18class _LDRTemplates(Jinja2Templates):
19 """Custom Jinja2Templates that injects CSRF token + flash messages
20 into every TemplateResponse, so routes can use templates.TemplateResponse
21 directly without losing CSRF/flash support.
22 """
24 def TemplateResponse( # noqa: N802 — match parent API
25 self,
26 *args: Any,
27 **kwargs: Any,
28 ) -> Response:
29 # Normalise: support both positional and keyword forms.
30 # Jinja2Templates.TemplateResponse(request, name, context, ...) OR
31 # Jinja2Templates.TemplateResponse(name, context, request=, ...) (legacy) OR
32 # Jinja2Templates.TemplateResponse(request=, name=, context=, ...)
33 context = kwargs.get("context") or {}
34 request = kwargs.get("request") or context.get("request")
36 if request is None and args:
37 # First positional could be Request
38 if isinstance(args[0], Request): 38 ↛ 41line 38 didn't jump to line 41 because the condition on line 38 was always true
39 request = args[0]
41 session = getattr(request, "session", None) if request else None
42 # Session is a Starlette mapping (dict-like); guard conservatively
43 # — the old check `session.__class__.get` crashed when session was
44 # None (NoneType has no `.get`) and was vacuously true otherwise.
45 if request is not None and hasattr(session, "get"): 45 ↛ 70line 45 didn't jump to line 70 because the condition on line 45 was always true
46 # Inject session so templates can do {{ session.username }}.
47 # Every base template reads session.username for the top bar.
48 context.setdefault("session", session)
50 # Inject CSRF token if not already present
51 if "csrf_token" not in context:
52 from .dependencies.csrf import generate_csrf_token
54 token = generate_csrf_token(request) # gitleaks:allow
55 context["csrf_token"] = lambda: token
57 # Inject flash messages if not already present
58 if "get_flashed_messages" not in context:
59 from .dependencies.flash import get_flashed_messages
61 flashes = get_flashed_messages(request, with_categories=True)
62 context["get_flashed_messages"] = lambda with_categories=False: (
63 flashes if with_categories else [msg for _, msg in flashes]
64 )
66 kwargs["context"] = context
68 # Inject frontend constants used by base.html (ports Flask's
69 # inject_frontend_constants context processor from app_factory.py).
70 if "research_status_enum" not in context: 70 ↛ 120line 70 didn't jump to line 120 because the condition on line 70 was always true
71 from ..constants import ResearchStatus
73 terminal = [
74 ResearchStatus.COMPLETED,
75 ResearchStatus.SUSPENDED,
76 ResearchStatus.FAILED,
77 ResearchStatus.ERROR,
78 ResearchStatus.CANCELLED,
79 ]
80 context["research_status_enum"] = {
81 m.name: m.value for m in ResearchStatus
82 }
83 context["research_terminal_states"] = [str(s) for s in terminal]
84 from ..constants import (
85 DEFAULT_LOCAL_SEARCH_CHUNK_OVERLAP,
86 DEFAULT_LOCAL_SEARCH_CHUNK_SIZE,
87 DEFAULT_LOCAL_SEARCH_DISTANCE_METRIC,
88 DEFAULT_LOCAL_SEARCH_INDEX_TYPE,
89 DEFAULT_LOCAL_SEARCH_MODEL,
90 DEFAULT_LOCAL_SEARCH_NORMALIZE_VECTORS,
91 DEFAULT_LOCAL_SEARCH_PROVIDER,
92 DEFAULT_LOCAL_SEARCH_SPLITTER_TYPE,
93 DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS,
94 HISTORY_LOGS_DEFAULT_LIMIT,
95 HISTORY_LOGS_HARD_CAP,
96 )
98 context["log_limits"] = {
99 "default": HISTORY_LOGS_DEFAULT_LIMIT,
100 "hard_cap": HISTORY_LOGS_HARD_CAP,
101 }
102 context["local_search_defaults"] = {
103 "provider": DEFAULT_LOCAL_SEARCH_PROVIDER,
104 "model": DEFAULT_LOCAL_SEARCH_MODEL,
105 "chunk_size": DEFAULT_LOCAL_SEARCH_CHUNK_SIZE,
106 "chunk_overlap": DEFAULT_LOCAL_SEARCH_CHUNK_OVERLAP,
107 "splitter_type": DEFAULT_LOCAL_SEARCH_SPLITTER_TYPE,
108 "text_separators": list(DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS),
109 "distance_metric": DEFAULT_LOCAL_SEARCH_DISTANCE_METRIC,
110 "normalize_vectors": DEFAULT_LOCAL_SEARCH_NORMALIZE_VECTORS,
111 "index_type": DEFAULT_LOCAL_SEARCH_INDEX_TYPE,
112 }
113 kwargs["context"] = context
115 # Inject the app version for every render (main did this via
116 # render_template_with_defaults' `version=__version__`). The sidebar
117 # renders a version badge linking to the matching release tag; without
118 # injection `version` is Undefined and every page shows an empty badge
119 # pointing at .../releases/tag/v.
120 if "version" not in context: 120 ↛ 132line 120 didn't jump to line 132 because the condition on line 120 was always true
121 from ..__version__ import __version__
123 context["version"] = __version__
124 kwargs["context"] = context
126 # Inject has_encryption for every render (main did this via
127 # render_template_with_defaults). settings_dashboard.html gates its
128 # "Database encryption is not available" security warning on
129 # `{% if not has_encryption %}`; without injection the variable is
130 # Undefined → the warning renders on every page load including
131 # fully encrypted installs, training users to ignore it.
132 if "has_encryption" not in context:
133 from ..database.encrypted_db import db_manager
135 context["has_encryption"] = db_manager.has_encryption
136 kwargs["context"] = context
138 # Inject the user's saved egress scope so base.html renders it onto
139 # <body data-scope=…> on EVERY page. Previously only routes going
140 # through dependencies/template_helpers.render_template got this;
141 # pages using templates.TemplateResponse directly (history, metrics,
142 # …) silently fell back to the module-level Jinja default, so the
143 # scope-aware CSS cues vanished on those pages. Fail open to the
144 # default — never crash a page render over a styling cue.
145 if "egress_scope" not in context:
146 from ..security.egress.policy import DEFAULT_EGRESS_SCOPE
148 scope = DEFAULT_EGRESS_SCOPE
149 try:
150 username = (
151 session.get("username") if hasattr(session, "get") else None
152 )
153 if username:
154 from ..database.session_context import (
155 get_user_db_session,
156 )
157 from ..utilities.db_utils import get_settings_manager
159 with get_user_db_session(username) as db_session:
160 if db_session: 160 ↛ 188line 160 didn't jump to line 188
161 sm = get_settings_manager(db_session, username)
162 scope = (
163 sm.get_setting(
164 "policy.egress_scope",
165 DEFAULT_EGRESS_SCOPE,
166 )
167 or DEFAULT_EGRESS_SCOPE
168 )
169 # Canonicalise before it reaches the template: this collapses case
170 # and whitespace and maps invalid / retired values to the
171 # protective default. Without it a stored "STRICT" renders
172 # data-scope="STRICT", and base.html's body[data-scope="strict"]
173 # selector -- attribute values are case-sensitive -- silently stops
174 # matching, so the strict-scope visual cue disappears. Lost in the
175 # port; main applied it at app_factory.py:585.
176 from ..security.egress.policy import (
177 effective_scope_for_display,
178 )
180 scope = effective_scope_for_display(scope)
181 except Exception:
182 from loguru import logger
184 logger.debug(
185 "Failed to read egress scope for template context",
186 exc_info=True,
187 )
188 context["egress_scope"] = scope
189 kwargs["context"] = context
191 return super().TemplateResponse(*args, **kwargs)
194try:
195 _PACKAGE_DIR = importlib_resources.files("local_deep_research") / "web"
196 with importlib_resources.as_file(_PACKAGE_DIR) as _pkg:
197 TEMPLATE_DIR = (_pkg / "templates").as_posix()
198 STATIC_DIR = (_pkg / "static").as_posix()
199except Exception:
200 TEMPLATE_DIR = str(Path("templates").resolve())
201 STATIC_DIR = str(Path("static").resolve())
203templates = _LDRTemplates(directory=TEMPLATE_DIR)