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
« 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"""
6from fastapi import APIRouter, Depends, Request
7from fastapi.responses import JSONResponse
8from loguru import logger
10from ...constants import DEFAULT_SEARCH_TOOL, get_available_strategies
11from ..dependencies.auth import require_auth
12from ..template_config import templates
13from typing import Annotated
15router = APIRouter(prefix="/news", tags=["news_pages"])
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
31 strategies = get_available_strategies()
33 return templates.TemplateResponse(
34 request=request,
35 name="pages/news.html",
36 context={"strategies": strategies},
37 )
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 )
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."""
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 }
77 from ...database.session_context import get_user_db_session
79 with get_user_db_session(username) as db_session:
80 _load_user_settings(default_settings, db_session, username)
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 )
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."""
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 }
121 try:
122 from ...news import api as news_api
124 subscription = news_api.get_subscription(
125 subscription_id, username=username
126 )
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 )
140 from ...database.session_context import get_user_db_session
142 with get_user_db_session(username) as db_session:
143 _load_user_settings(default_settings, db_session, username)
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 )
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 )
169@router.get("/health")
170def news_health_check(username: Annotated[str, Depends(require_auth)]):
171 """Check if news system is healthy (authenticated users only).
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
182 storage = StorageManager()
183 storage.get_user_feed(username, limit=1)
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 )
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
206 try:
207 from ...utilities.db_utils import get_settings_manager
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")