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

58 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +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 

9 

10from ...constants import DEFAULT_SEARCH_TOOL, get_available_strategies 

11from ..dependencies.auth import require_auth 

12from ..template_config import templates 

13from typing import Annotated 

14 

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

16 

17 

18@router.get("/") 

19def news_page( 

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

21): 

22 """Render the main news page.""" 

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

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

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

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

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

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

29 from ...constants import get_available_strategies 

30 

31 strategies = get_available_strategies() 

32 

33 return templates.TemplateResponse( 

34 request=request, 

35 name="pages/news.html", 

36 context={"strategies": strategies}, 

37 ) 

38 

39 

40@router.get("/subscriptions") 

41def subscriptions_page( 

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

43): 

44 """Render the subscriptions management page.""" 

45 return templates.TemplateResponse( 

46 request=request, 

47 name="pages/subscriptions.html", 

48 context={}, 

49 ) 

50 

51 

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

53def new_subscription_page( 

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

55): 

56 """Render the create subscription page.""" 

57 

58 default_settings = { 

59 "iterations": 3, 

60 "questions_per_iteration": 5, 

61 "search_engine": DEFAULT_SEARCH_TOOL, 

62 "model_provider": "ollama", 

63 "model": "", 

64 "search_strategy": "source-based", 

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

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

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

68 # anonymous / no-DB paths. 

69 "egress_scope": "adaptive", 

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

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

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

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

74 "custom_endpoint": "", 

75 } 

76 

77 from ...database.session_context import get_user_db_session 

78 

79 with get_user_db_session(username) as db_session: 

80 _load_user_settings(default_settings, db_session, username) 

81 

82 return templates.TemplateResponse( 

83 request=request, 

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

85 context={ 

86 "subscription": None, 

87 "default_settings": default_settings, 

88 "strategies": get_available_strategies(), 

89 }, 

90 ) 

91 

92 

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

94def edit_subscription_page( 

95 request: Request, 

96 subscription_id: str, 

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

98): 

99 """Render the edit subscription page.""" 

100 

101 subscription = None 

102 default_settings = { 

103 "iterations": 3, 

104 "questions_per_iteration": 5, 

105 "search_engine": DEFAULT_SEARCH_TOOL, 

106 "model_provider": "ollama", 

107 "model": "", 

108 "search_strategy": "source-based", 

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

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

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

112 # anonymous / no-DB paths. 

113 "egress_scope": "adaptive", 

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

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

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

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

118 "custom_endpoint": "", 

119 } 

120 

121 try: 

122 from ...news import api as news_api 

123 

124 subscription = news_api.get_subscription( 

125 subscription_id, username=username 

126 ) 

127 

128 if not subscription: 

129 return templates.TemplateResponse( 

130 request=request, 

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

132 context={ 

133 "subscription": None, 

134 "error": "Subscription not found", 

135 "default_settings": default_settings, 

136 "strategies": get_available_strategies(), 

137 }, 

138 ) 

139 

140 from ...database.session_context import get_user_db_session 

141 

142 with get_user_db_session(username) as db_session: 

143 _load_user_settings(default_settings, db_session, username) 

144 

145 except Exception: 

146 logger.exception(f"Error loading subscription {subscription_id}") 

147 return templates.TemplateResponse( 

148 request=request, 

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

150 context={ 

151 "subscription": None, 

152 "error": "Error loading subscription", 

153 "default_settings": default_settings, 

154 "strategies": get_available_strategies(), 

155 }, 

156 ) 

157 

158 return templates.TemplateResponse( 

159 request=request, 

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

161 context={ 

162 "subscription": subscription, 

163 "default_settings": default_settings, 

164 "strategies": get_available_strategies(), 

165 }, 

166 ) 

167 

168 

169@router.get("/health") 

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

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

172 

173 The old public version probed the StorageManager with a hardcoded 

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

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

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

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

178 """ 

179 try: 

180 from ...news.core.storage_manager import StorageManager 

181 

182 storage = StorageManager() 

183 storage.get_user_feed(username, limit=1) 

184 

185 return { 

186 "status": "healthy", 

187 "enabled": True, 

188 "database": "connected", 

189 } 

190 except Exception: 

191 logger.exception("Health check failed") 

192 return JSONResponse( 

193 { 

194 "status": "unhealthy", 

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

196 }, 

197 status_code=500, 

198 ) 

199 

200 

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

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

203 if not db_session: 

204 return 

205 

206 try: 

207 from ...utilities.db_utils import get_settings_manager 

208 

209 settings_manager = get_settings_manager(db_session, username) 

210 default_settings.update( 

211 { 

212 "iterations": settings_manager.get_setting( 

213 "search.iterations", 3 

214 ), 

215 "questions_per_iteration": settings_manager.get_setting( 

216 "search.questions_per_iteration", 5 

217 ), 

218 "search_engine": settings_manager.get_setting( 

219 "search.tool", DEFAULT_SEARCH_TOOL 

220 ), 

221 "model_provider": settings_manager.get_setting( 

222 "llm.provider", "ollama" 

223 ), 

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

225 "search_strategy": settings_manager.get_setting( 

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

227 ), 

228 "custom_endpoint": settings_manager.get_setting( 

229 "llm.openai_endpoint.url", "" 

230 ), 

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

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

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

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

235 # egress-aware API URL. 

236 "egress_scope": settings_manager.get_setting( 

237 "policy.egress_scope", "adaptive" 

238 ), 

239 } 

240 ) 

241 except Exception: 

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