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

1""" 

2Shared Jinja2 templates instance. 

3 

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""" 

8 

9from importlib import resources as importlib_resources 

10from pathlib import Path 

11from typing import Any 

12 

13from fastapi.templating import Jinja2Templates 

14from starlette.requests import Request 

15from starlette.responses import Response 

16 

17 

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 """ 

23 

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") 

35 

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] 

40 

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) 

49 

50 # Inject CSRF token if not already present 

51 if "csrf_token" not in context: 

52 from .dependencies.csrf import generate_csrf_token 

53 

54 token = generate_csrf_token(request) # gitleaks:allow 

55 context["csrf_token"] = lambda: token 

56 

57 # Inject flash messages if not already present 

58 if "get_flashed_messages" not in context: 

59 from .dependencies.flash import get_flashed_messages 

60 

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 ) 

65 

66 kwargs["context"] = context 

67 

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 

72 

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 ) 

97 

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 

114 

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__ 

122 

123 context["version"] = __version__ 

124 kwargs["context"] = context 

125 

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 

134 

135 context["has_encryption"] = db_manager.has_encryption 

136 kwargs["context"] = context 

137 

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 

147 

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 

158 

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 ) 

179 

180 scope = effective_scope_for_display(scope) 

181 except Exception: 

182 from loguru import logger 

183 

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 

190 

191 return super().TemplateResponse(*args, **kwargs) 

192 

193 

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()) 

202 

203templates = _LDRTemplates(directory=TEMPLATE_DIR)