Coverage for src/local_deep_research/web/routers/news_pages.py: 100%

64 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1""" 

2FastAPI router for news system page routes. 

3Ported from news/web.py Flask blueprint factory. 

4""" 

5 

6from fastapi import APIRouter, Depends, Request 

7from fastapi.responses import JSONResponse 

8from loguru import logger 

9from .notes import _log_value_preview 

10 

11from ...constants import DEFAULT_SEARCH_TOOL, get_available_strategies 

12from ...settings.exceptions import SettingsValueError 

13from ..dependencies.auth import require_auth 

14from ..template_config import templates 

15from typing import Annotated 

16 

17router = APIRouter(prefix="/news", tags=["news_pages"]) 

18 

19 

20@router.get("/") 

21def news_page( 

22 request: Request, username: Annotated[str, Depends(require_auth)] 

23): 

24 """Render the main news page.""" 

25 # news.html renders <option value="{{ s.name }}">{{ s.label }}</option>, 

26 # so it needs the {name,label,description} dicts get_available_strategies 

27 # returns — the source main used. The previous hardcoded string list 

28 # ("topic_based", "news_aggregation", ...) was both the wrong shape 

29 # (every option rendered blank) AND wrong names (those strategies don't 

30 # exist; the real ones are "source-based", "focused-iteration", ...). 

31 from ...constants import get_available_strategies 

32 

33 strategies = get_available_strategies() 

34 

35 return templates.TemplateResponse( 

36 request=request, 

37 name="pages/news.html", 

38 context={"strategies": strategies}, 

39 ) 

40 

41 

42@router.get("/subscriptions") 

43def subscriptions_page( 

44 request: Request, username: Annotated[str, Depends(require_auth)] 

45): 

46 """Render the subscriptions management page.""" 

47 return templates.TemplateResponse( 

48 request=request, 

49 name="pages/subscriptions.html", 

50 context={}, 

51 ) 

52 

53 

54@router.get("/subscriptions/new") 

55def new_subscription_page( 

56 request: Request, username: Annotated[str, Depends(require_auth)] 

57): 

58 """Render the create subscription page.""" 

59 

60 default_settings = { 

61 "iterations": 3, 

62 "questions_per_iteration": 5, 

63 "search_engine": DEFAULT_SEARCH_TOOL, 

64 "model_provider": "ollama", 

65 "model": "", 

66 "search_strategy": "source-based", 

67 # Issue #5204: scope key for the egress-aware search-engine dropdown. 

68 # ``_load_user_settings`` overwrites this with the user's saved value; 

69 # the hardcoded default is the safe-by-default ``adaptive`` for 

70 # anonymous / no-DB paths. 

71 "egress_scope": "adaptive", 

72 # Required, not cosmetic: the template renders this through 

73 # ``| tojson``, and Jinja's Undefined is not JSON-serialisable, so 

74 # omitting it turns the not-found and error branches (which skip 

75 # ``_load_user_settings``) into a 500 rather than a rendered page. 

76 "custom_endpoint": "", 

77 } 

78 

79 from ...database.session_context import get_user_db_session 

80 

81 with get_user_db_session(username) as db_session: 

82 _load_user_settings(default_settings, db_session, username) 

83 

84 return templates.TemplateResponse( 

85 request=request, 

86 name="pages/news-subscription-form.html", 

87 context={ 

88 "subscription": None, 

89 "default_settings": default_settings, 

90 "strategies": get_available_strategies(), 

91 }, 

92 ) 

93 

94 

95@router.get("/subscriptions/{subscription_id}/edit") 

96def edit_subscription_page( 

97 request: Request, 

98 subscription_id: str, 

99 username: Annotated[str, Depends(require_auth)], 

100): 

101 """Render the edit subscription page.""" 

102 

103 subscription = None 

104 default_settings = { 

105 "iterations": 3, 

106 "questions_per_iteration": 5, 

107 "search_engine": DEFAULT_SEARCH_TOOL, 

108 "model_provider": "ollama", 

109 "model": "", 

110 "search_strategy": "source-based", 

111 # Issue #5204: scope key for the egress-aware search-engine dropdown. 

112 # ``_load_user_settings`` overwrites this with the user's saved value; 

113 # the hardcoded default is the safe-by-default ``adaptive`` for 

114 # anonymous / no-DB paths. 

115 "egress_scope": "adaptive", 

116 # Required, not cosmetic: the template renders this through 

117 # ``| tojson``, and Jinja's Undefined is not JSON-serialisable, so 

118 # omitting it turns the not-found and error branches (which skip 

119 # ``_load_user_settings``) into a 500 rather than a rendered page. 

120 "custom_endpoint": "", 

121 } 

122 

123 try: 

124 from ...news import api as news_api 

125 

126 subscription = news_api.get_subscription( 

127 subscription_id, username=username 

128 ) 

129 

130 if not subscription: 

131 return templates.TemplateResponse( 

132 request=request, 

133 name="pages/news-subscription-form.html", 

134 context={ 

135 "subscription": None, 

136 "error": "Subscription not found", 

137 "default_settings": default_settings, 

138 "strategies": get_available_strategies(), 

139 }, 

140 ) 

141 

142 from ...database.session_context import get_user_db_session 

143 

144 with get_user_db_session(username) as db_session: 

145 _load_user_settings(default_settings, db_session, username) 

146 

147 except SettingsValueError: 

148 # Not a subscription loading error: the app's handler names the 

149 # invalid setting. 

150 raise 

151 except Exception: 

152 logger.exception( 

153 f"Error loading subscription {_log_value_preview(subscription_id)}" 

154 ) 

155 return templates.TemplateResponse( 

156 request=request, 

157 name="pages/news-subscription-form.html", 

158 context={ 

159 "subscription": None, 

160 "error": "Error loading subscription", 

161 "default_settings": default_settings, 

162 "strategies": get_available_strategies(), 

163 }, 

164 ) 

165 

166 return templates.TemplateResponse( 

167 request=request, 

168 name="pages/news-subscription-form.html", 

169 context={ 

170 "subscription": subscription, 

171 "default_settings": default_settings, 

172 "strategies": get_available_strategies(), 

173 }, 

174 ) 

175 

176 

177@router.get("/health") 

178def news_health_check(username: Annotated[str, Depends(require_auth)]): 

179 """Check if news system is healthy (authenticated users only). 

180 

181 The old public version probed the StorageManager with a hardcoded 

182 `user_id="health_check"` sentinel — leaking infrastructure state 

183 to unauthenticated callers AND creating a spurious DB row on every 

184 invocation. `/api/v1/health` already exists as the public liveness 

185 probe; gate this one behind auth and scope it to the caller. 

186 """ 

187 try: 

188 from ...news.core.storage_manager import StorageManager 

189 

190 storage = StorageManager() 

191 storage.get_user_feed(username, limit=1) 

192 

193 return { 

194 "status": "healthy", 

195 "enabled": True, 

196 "database": "connected", 

197 } 

198 except Exception: 

199 logger.exception("Health check failed") 

200 return JSONResponse( 

201 { 

202 "status": "unhealthy", 

203 "error": "An internal error has occurred.", 

204 }, 

205 status_code=500, 

206 ) 

207 

208 

209def _load_user_settings(default_settings, db_session=None, username=None): 

210 """Load user settings and update default_settings dictionary.""" 

211 if not db_session: 

212 return 

213 

214 try: 

215 from ...utilities.db_utils import get_settings_manager 

216 

217 settings_manager = get_settings_manager(db_session, username) 

218 default_settings.update( 

219 { 

220 "iterations": settings_manager.get_setting( 

221 "search.iterations", 3 

222 ), 

223 "questions_per_iteration": settings_manager.get_setting( 

224 "search.questions_per_iteration", 5 

225 ), 

226 "search_engine": settings_manager.get_setting( 

227 "search.tool", DEFAULT_SEARCH_TOOL 

228 ), 

229 "model_provider": settings_manager.get_setting( 

230 "llm.provider", "ollama" 

231 ), 

232 "model": settings_manager.get_setting("llm.model", ""), 

233 "search_strategy": settings_manager.get_setting( 

234 "search.search_strategy", "source-based" 

235 ), 

236 "custom_endpoint": settings_manager.get_setting( 

237 "llm.openai_endpoint.url", "" 

238 ), 

239 # Issue #5204: the news-subscription form's search-engine 

240 # dropdown is scope-aware — it loads the engine list with the 

241 # user's saved egress scope so incompatible options render 

242 # disabled. Pull the saved value so the template can build the 

243 # egress-aware API URL. 

244 "egress_scope": settings_manager.get_setting( 

245 "policy.egress_scope", "adaptive" 

246 ), 

247 } 

248 ) 

249 except SettingsValueError: 

250 # An invalid configured value must not prefill the form with 

251 # defaults as if they were the saved settings. 

252 raise 

253 except Exception: 

254 logger.warning("Could not load user settings")