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
« 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"""
6from fastapi import APIRouter, Depends, Request
7from fastapi.responses import JSONResponse
8from loguru import logger
9from .notes import _log_value_preview
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
17router = APIRouter(prefix="/news", tags=["news_pages"])
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
33 strategies = get_available_strategies()
35 return templates.TemplateResponse(
36 request=request,
37 name="pages/news.html",
38 context={"strategies": strategies},
39 )
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 )
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."""
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 }
79 from ...database.session_context import get_user_db_session
81 with get_user_db_session(username) as db_session:
82 _load_user_settings(default_settings, db_session, username)
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 )
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."""
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 }
123 try:
124 from ...news import api as news_api
126 subscription = news_api.get_subscription(
127 subscription_id, username=username
128 )
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 )
142 from ...database.session_context import get_user_db_session
144 with get_user_db_session(username) as db_session:
145 _load_user_settings(default_settings, db_session, username)
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 )
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 )
177@router.get("/health")
178def news_health_check(username: Annotated[str, Depends(require_auth)]):
179 """Check if news system is healthy (authenticated users only).
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
190 storage = StorageManager()
191 storage.get_user_feed(username, limit=1)
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 )
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
214 try:
215 from ...utilities.db_utils import get_settings_manager
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")