Coverage for src/local_deep_research/web/routers/news_flask_api.py: 93%

733 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1""" 

2News API endpoints (FastAPI ``APIRouter``, mounted at ``/news/api``). 

3 

4The module name is a leftover: this code was FastAPI, was converted to a 

5Flask blueprint to match LDR's then-Flask architecture, and the ASGI port 

6converted it back. The name was kept across the port so importers and 

7patch targets stay stable. 

8""" 

9 

10import asyncio 

11 

12from fastapi import APIRouter, Depends, HTTPException, Request 

13from fastapi.responses import JSONResponse 

14from ..dependencies.auth import require_auth 

15from ..dependencies.rate_limit import limiter 

16from ..dependencies.threadpool import run_db_sync 

17 

18import uuid 

19from typing import Any, Annotated 

20 

21from loguru import logger 

22 

23from ...llm.providers.base import normalize_provider 

24from ...news import api 

25from ...news.exceptions import NewsAPIException 

26from ...news.folder_manager import FolderManager 

27from ...database.models import SubscriptionFolder 

28 

29from ...database.session_context import get_user_db_session 

30from ...settings.env_registry import get_env_setting 

31from ...utilities.db_utils import get_settings_manager 

32from ...utilities.url_utils import is_safe_custom_llm_endpoint 

33from ..dependencies.json_body import json_body_error, read_json_dict 

34 

35# Hard ceiling for user-supplied ``limit`` query params on the news 

36# endpoints in this module. Matches the ``max_value`` of the 

37# ``news.feed.default_limit`` setting so a direct API caller cannot request a 

38# larger page than the UI can configure. Shared by the feed and 

39# subscription-history endpoints here so their caps stay in lockstep. 

40NEWS_FEED_MAX_LIMIT = 100 

41# The service currently performs three queries per card ID. Keep the batch 

42# endpoint bounded to one feed page so a malformed or oversized request cannot 

43# amplify database work independently of the feed's own pagination ceiling. 

44NEWS_BATCH_FEEDBACK_MAX_CARD_IDS = NEWS_FEED_MAX_LIMIT 

45 

46# Subscription scheduling and search controls arrive as untyped JSON. Keep 

47# their request-boundary limits in one place so the create route and both 

48# update routes cannot drift apart. One minute and one week are both offered 

49# by the current UI, so those are the inclusive refresh bounds. 

50NEWS_SUBSCRIPTION_MIN_REFRESH_MINUTES = 1 

51NEWS_SUBSCRIPTION_MAX_REFRESH_MINUTES = 10_080 

52NEWS_SUBSCRIPTION_MAX_QUERY_LENGTH = 10_000 

53NEWS_SUBSCRIPTION_MAX_SEARCH_ITERATIONS = 20 

54NEWS_SUBSCRIPTION_MAX_QUESTIONS_PER_ITERATION = 10 

55 

56 

57def _subscription_validation_error(message: str) -> JSONResponse: 

58 """Return the stable simple-error shape used by the news API.""" 

59 return JSONResponse({"error": message}, status_code=400) 

60 

61 

62def _validate_subscription_payload( 

63 data: dict[str, Any], 

64 *, 

65 require_query: bool = False, 

66 query_field: str = "query", 

67 refresh_field: str = "refresh_minutes", 

68) -> JSONResponse | None: 

69 """Validate fields shared by subscription create/update payloads. 

70 

71 ``bool`` is a subclass of ``int`` in Python, so the numeric checks use an 

72 exact type comparison. This helper intentionally runs before any DB, 

73 scheduler, endpoint-resolution, or service work at each call site. 

74 """ 

75 has_query = query_field in data 

76 if require_query and not has_query: 

77 return _subscription_validation_error("query is required") 

78 if has_query: 

79 query = data[query_field] 

80 if query is None and require_query: 

81 return _subscription_validation_error("query is required") 

82 if not isinstance(query, str): 

83 return _subscription_validation_error("query must be a string") 

84 if not query.strip(): 

85 return _subscription_validation_error("query is required") 

86 if len(query) > NEWS_SUBSCRIPTION_MAX_QUERY_LENGTH: 

87 return _subscription_validation_error( 

88 "query exceeds maximum length of " 

89 f"{NEWS_SUBSCRIPTION_MAX_QUERY_LENGTH} characters" 

90 ) 

91 

92 if "is_active" in data and not isinstance(data["is_active"], bool): 

93 return _subscription_validation_error("is_active must be a boolean") 

94 

95 for field in ("name", "folder_id", "model_provider"): 

96 if ( 

97 field in data 

98 and data[field] is not None 

99 and not isinstance(data[field], str) 

100 ): 

101 return _subscription_validation_error( 

102 f"{field} must be a string or null" 

103 ) 

104 

105 integer_ranges = ( 

106 ( 

107 refresh_field, 

108 NEWS_SUBSCRIPTION_MIN_REFRESH_MINUTES, 

109 NEWS_SUBSCRIPTION_MAX_REFRESH_MINUTES, 

110 ), 

111 ("search_iterations", 1, NEWS_SUBSCRIPTION_MAX_SEARCH_ITERATIONS), 

112 ( 

113 "questions_per_iteration", 

114 1, 

115 NEWS_SUBSCRIPTION_MAX_QUESTIONS_PER_ITERATION, 

116 ), 

117 ) 

118 for field, minimum, maximum in integer_ranges: 

119 if field not in data: 

120 continue 

121 value = data[field] 

122 if type(value) is not int or not minimum <= value <= maximum: 

123 return _subscription_validation_error( 

124 f"{field} must be an integer between {minimum} and {maximum}" 

125 ) 

126 

127 return None 

128 

129 

130def require_scheduler_control(request: Request) -> None: 

131 """Dependency that gates global scheduler control behind a setting. 

132 

133 The news scheduler is a global singleton — starting, stopping, or 

134 triggering it affects all users. This checks the 

135 ``news.scheduler.allow_api_control`` setting (env var 

136 ``LDR_NEWS_SCHEDULER_ALLOW_API_CONTROL``, default ``false``) and 

137 raises HTTPException 403 when disabled. 

138 """ 

139 if not get_env_setting("news.scheduler.allow_api_control", False): 

140 # Every route using this dependency declares Depends(require_auth) 

141 # ahead of it, and FastAPI resolves declared dependencies in order, 

142 # so session["username"] is guaranteed here. Index rather than 

143 # .get(..., "unknown"): this is an audit line for a blocked 

144 # privileged action, and a shared "unknown" placeholder silently 

145 # merges every such attempt into one unattributable entry if that 

146 # ordering invariant is ever broken. Failing fast surfaces the 

147 # broken invariant instead of quietly degrading the audit trail. 

148 # Ported from #5549, which made the same change on main. 

149 username = request.session["username"] 

150 remote_addr = request.client.host if request.client else "unknown" 

151 logger.warning( 

152 "Scheduler API control blocked (user={}, ip={})", 

153 username, 

154 remote_addr, 

155 ) 

156 raise HTTPException( 

157 status_code=403, 

158 detail=( 

159 "Scheduler API control is disabled. " 

160 "Contact your administrator to enable it." 

161 ), 

162 ) 

163 

164 

165def safe_error_message(e: Exception, context: str = "") -> str: 

166 """ 

167 Return a safe error message that doesn't expose internal details. 

168 

169 Args: 

170 e: The exception 

171 context: Optional context about what was being attempted 

172 

173 Returns: 

174 A generic error message safe for external users 

175 """ 

176 # Log the actual error for debugging 

177 logger.exception(f"Error in {context}") 

178 

179 # Return generic messages based on exception type 

180 if isinstance(e, ValueError): 

181 return "Invalid input provided" 

182 if isinstance(e, KeyError): 

183 return "Required data missing" 

184 if isinstance(e, TypeError): 

185 return "Invalid data format" 

186 # Generic message for production 

187 return f"An error occurred{f' while {context}' if context else ''}" 

188 

189 

190def _is_job_owned_by_user(job, username, scheduler): 

191 """Check if an APScheduler job belongs to a specific user.""" 

192 # Primary: all news scheduler jobs pass username as first arg 

193 if hasattr(job, "args") and job.args and job.args[0] == username: 

194 return True 

195 # Fallback: check the tracked scheduled_jobs set 

196 if hasattr(scheduler, "user_sessions"): 

197 session_info = scheduler.user_sessions.get(username, {}) 

198 if job.id in session_info.get("scheduled_jobs", set()): 

199 return True 

200 return False 

201 

202 

203# In Flask, this Blueprint had no prefix but was nested under /news. 

204# In FastAPI, routers are flat, so we add the full prefix. 

205router = APIRouter(prefix="/news/api", tags=["news_api"]) 

206 

207# Shared rate limits for POST endpoints (port of main's news_routes 

208# Flask-Limiter shared limits — one bucket per scope). 

209_news_create_limit = limiter.shared_limit("10 per minute", scope="news_create") 

210_news_research_limit = limiter.shared_limit( 

211 "5 per minute", scope="news_research" 

212) 

213_news_feedback_limit = limiter.shared_limit( 

214 "30 per minute", scope="news_feedback" 

215) 

216_news_preferences_limit = limiter.shared_limit( 

217 "10 per minute", scope="news_preferences" 

218) 

219# NOTE: Routes use username (not .get()) intentionally. 

220# Depends(require_auth) guarantees the key exists; direct access fails 

221# fast if the dependency is ever removed. 

222 

223# Components are initialized in api.py 

224 

225 

226def get_user_id(request=None): 

227 """Get current user ID from the FastAPI request session. 

228 

229 Pass `request` to read it from the active session. If omitted (legacy 

230 callers), returns None — these routes should be using 

231 `Depends(require_auth)` directly instead of this helper. 

232 """ 

233 if request is None: 

234 return None 

235 username = ( 

236 request.session.get("username") if hasattr(request, "session") else None 

237 ) 

238 if not username: 

239 # For news, we need authenticated users 

240 return None 

241 

242 return username 

243 

244 

245def _start_research_in_process(request, request_data: dict, username: str): 

246 """Start a research run by calling the research router's sync entrypoint 

247 directly, instead of a loopback HTTP POST. 

248 

249 The two news "run subscription now" / "check overdue" paths used to POST 

250 to ``<request.base_url>/research/api/start`` via ``safe_post``. Because 

251 ``request.base_url`` is derived from the client-controlled ``Host`` 

252 header and ``safe_post`` was called with ``allow_localhost=True, 

253 allow_private_ips=True``, a spoofed ``Host`` turned this into an SSRF: the 

254 server would issue a request carrying the caller's own session cookie + 

255 CSRF token to an attacker-named internal host, and ``check-overdue`` 

256 reflected the raw response body back to the client. Calling the handler 

257 in-process removes the network hop (and the SSRF) entirely — the same 

258 move main made in #4834. 

259 

260 Returns the research-start result as a plain dict (``status``, 

261 ``research_id``, …), normalizing ``_start_research_sync``'s JSONResponse 

262 error returns into the same dict shape the callers expect. 

263 """ 

264 import json as _json 

265 

266 from .research import _start_research_sync 

267 

268 # base_url is only embedded cosmetically (server_url for link 

269 # generation) inside _start_research_sync — it drives no network call, 

270 # so passing the Host-derived value here is inert (matches what the 

271 # direct /research/api/start endpoint passes). 

272 base_url = str(request.base_url).rstrip("/") 

273 session_id = request.session.get("session_id") 

274 

275 result = _start_research_sync(request_data, username, base_url, session_id) 

276 if isinstance(result, JSONResponse): 276 ↛ 282line 276 didn't jump to line 282 because the condition on line 276 was never true

277 # _start_research_sync returns a JSONResponse on failure with a 

278 # meaningful status_code (400 validation, 409 duplicate, 429 

279 # capacity). Preserve it under `_http_status` so the caller can 

280 # propagate the real code instead of collapsing everything to 500 

281 # (the old loopback forwarded response.status_code). 

282 try: 

283 body = _json.loads(bytes(result.body).decode("utf-8")) 

284 except Exception: 

285 body = {"status": "error", "message": "Failed to start research"} 

286 body["_http_status"] = result.status_code 

287 return body 

288 if isinstance(result, dict): 288 ↛ 293line 288 didn't jump to line 293 because the condition on line 288 was always true

289 # Normalize a ResearchStatus enum status to its str value so the 

290 # callers' `in ("success", "queued")` check is robust. 

291 status = result.get("status") 

292 return {**result, "status": getattr(status, "value", status)} 

293 return {"status": "error", "message": "Failed to start research"} 

294 

295 

296async def _reject_custom_endpoint_async(custom_endpoint): 

297 """Off-loop wrapper for :func:`_reject_custom_endpoint`. 

298 

299 SSRF validation ends in ``socket.getaddrinfo`` (see 

300 ``security/ssrf_validator.py``), which is a blocking OS resolver call 

301 with no timeout parameter — it cannot be bounded in place. Called 

302 directly from an ``async def`` handler it stalls the event loop, and 

303 the server runs single-worker, so every other in-flight request stalls 

304 with it. An authenticated caller supplying a hostname that resolves 

305 slowly or black-holes would hold the whole process for the resolver's 

306 full retry budget. Hand it to a worker thread instead. 

307 """ 

308 return await asyncio.to_thread(_reject_custom_endpoint, custom_endpoint) 

309 

310 

311def _reject_custom_endpoint(custom_endpoint): 

312 """Return a 400 JSONResponse if ``custom_endpoint`` fails SSRF 

313 validation, else None. 

314 

315 Blocking (DNS). From an ``async def`` handler call 

316 :func:`_reject_custom_endpoint_async` instead of this directly. 

317 

318 Rejects cloud-metadata / link-local targets at the request boundary as 

319 fail-fast defense-in-depth (the OpenAI-compatible provider re-validates 

320 the same URL via assert_base_url_safe before the client is built). 

321 Private IPs and localhost are allowed because local LLMs live there; 

322 scheme-less endpoints are normalized exactly as the provider does. 

323 """ 

324 if is_safe_custom_llm_endpoint(custom_endpoint): 

325 return None 

326 return JSONResponse( 

327 {"success": False, "error": "Invalid custom endpoint URL"}, 

328 status_code=400, 

329 ) 

330 

331 

332# The documented sentinel meaning "no subscription filter". Upstream 

333# (#5603) exempts it from the UUID check on the feed route; without 

334# this, ?subscription_id=all 400s even though news/api.py still 

335# implements the "all" branch. 

336NO_SUBSCRIPTION_FILTER = "all" 

337 

338 

339def _is_valid_uuid(value: str) -> bool: 

340 """Return True if ``value`` parses as a UUID, False otherwise. 

341 

342 Used to validate subscription_id params before they reach the 

343 LIKE-pattern queries in ``news/api.py``. Without this check, a request 

344 like ``?subscription_id=%`` would expand the LIKE filter and match 

345 arbitrary subscriptions (enumeration vector). 

346 """ 

347 try: 

348 uuid.UUID(str(value)) 

349 except (ValueError, AttributeError, TypeError): 

350 return False 

351 return True 

352 

353 

354@router.get("/feed") 

355def get_news_feed( 

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

357) -> Any: 

358 """ 

359 Get personalized news feed for user. 

360 

361 Query params: 

362 user_id: User identifier (default: anonymous) 

363 limit: Maximum number of cards to return (default: 20) 

364 use_cache: Whether to use cached news (default: true) 

365 strategy: Override default recommendation strategy 

366 focus: Optional focus area for news 

367 """ 

368 try: 

369 # Get current user (require_auth ensures we have one) 

370 user_id = username 

371 logger.info(f"News feed requested by user: {user_id}") 

372 

373 # Get query parameters 

374 with get_user_db_session(username) as _db: 

375 settings_manager = get_settings_manager(_db, username) 

376 default_limit = settings_manager.get_setting( 

377 "news.feed.default_limit" 

378 ) 

379 try: 

380 limit = int(request.query_params.get("limit", default_limit)) 

381 except (TypeError, ValueError): 

382 limit = int(default_limit) 

383 limit = max(1, min(limit, NEWS_FEED_MAX_LIMIT)) 

384 use_cache = ( 

385 request.query_params.get("use_cache", "true").lower() == "true" 

386 ) 

387 strategy = request.query_params.get("strategy") 

388 focus = request.query_params.get("focus") 

389 subscription_id = request.query_params.get("subscription_id") 

390 if ( 

391 subscription_id 

392 and subscription_id != NO_SUBSCRIPTION_FILTER 

393 and not _is_valid_uuid(subscription_id) 

394 ): 

395 return JSONResponse( 

396 {"success": False, "error": "Invalid subscription_id"}, 

397 status_code=400, 

398 ) 

399 

400 logger.info( 

401 f"News feed params: limit={limit}, subscription_id={subscription_id}, focus={focus}" 

402 ) 

403 

404 # Call the direct API function (now synchronous) 

405 result = api.get_news_feed( 

406 user_id=user_id, 

407 limit=limit, 

408 use_cache=use_cache, 

409 focus=focus, 

410 search_strategy=strategy, 

411 subscription_id=subscription_id, 

412 ) 

413 

414 # Check for errors in result 

415 if "error" in result and result.get("news_items") == []: 

416 # Sanitize error message before returning to client 

417 safe_msg = safe_error_message( 

418 Exception(result["error"]), context="get_news_feed" 

419 ) 

420 status = 400 if "must be between" in result["error"] else 500 

421 return JSONResponse( 

422 {"error": safe_msg, "news_items": []}, status_code=status 

423 ) 

424 

425 # Debug: Log the result before returning 

426 logger.info( 

427 f"API returning {len(result.get('news_items', []))} news items" 

428 ) 

429 if result.get("news_items"): 

430 logger.info( 

431 f"First item ID: {result['news_items'][0].get('id', 'NO_ID')}" 

432 ) 

433 

434 return result 

435 

436 except NewsAPIException: 

437 # api.get_news_feed() raises DatabaseAccessException / 

438 # NewsFeedGenerationException on failure — let the NewsAPIException 

439 # handler in fastapi_app.py render exc.to_dict() (error_code, 

440 # details) instead of downgrading it to a generic {"error": ...} 

441 # below. 

442 raise 

443 except Exception as e: 

444 return JSONResponse( 

445 { 

446 "error": safe_error_message(e, "getting news feed"), 

447 "news_items": [], 

448 }, 

449 status_code=500, 

450 ) 

451 

452 

453@router.post("/subscribe") 

454@_news_create_limit 

455async def create_subscription( 

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

457) -> Any: 

458 """ 

459 Create a new subscription for user. 

460 

461 JSON body: 

462 query: Search query or topic 

463 subscription_type: "search" or "topic" (default: "search") 

464 refresh_minutes: Refresh interval in minutes (default: from settings) 

465 """ 

466 try: 

467 data, _body_err = await read_json_dict( 

468 request, "simple", "No JSON data provided" 

469 ) 

470 if _body_err is not None: 

471 return _body_err 

472 except Exception: 

473 # Handle invalid JSON 

474 return JSONResponse({"error": "Invalid JSON data"}, status_code=400) 

475 

476 try: 

477 validation_error = _validate_subscription_payload( 

478 data, require_query=True 

479 ) 

480 if validation_error is not None: 

481 return validation_error 

482 

483 # Get current user 

484 user_id = username 

485 

486 # Extract parameters 

487 query = data.get("query") 

488 subscription_type = data.get("subscription_type", "search") 

489 refresh_minutes = data.get( 

490 "refresh_minutes" 

491 ) # Will use default from api.py 

492 

493 # Extract model configuration (optional) 

494 # Normalised to the lowercase canonical form the rest of the 

495 # route/service layer compares against (and falsy -> None), so a 

496 # subscription created with "OLLAMA" still matches later lookups. 

497 model_provider = normalize_provider(data.get("model_provider")) 

498 model = data.get("model") 

499 search_strategy = data.get("search_strategy", "news_aggregation") 

500 custom_endpoint = data.get("custom_endpoint") 

501 

502 # Extract additional fields 

503 name = data.get("name") 

504 folder_id = data.get("folder_id") 

505 is_active = data.get("is_active", True) 

506 search_engine = data.get("search_engine") 

507 search_iterations = data.get("search_iterations") 

508 questions_per_iteration = data.get("questions_per_iteration") 

509 

510 bad_endpoint = await _reject_custom_endpoint_async(custom_endpoint) 

511 if bad_endpoint is not None: 

512 return bad_endpoint 

513 

514 # Sync DB work (SQLCipher session + commit) — threadpool, not loop. 

515 return await run_db_sync( 

516 api.create_subscription, 

517 user_id=user_id, 

518 query=query, 

519 subscription_type=subscription_type, 

520 refresh_minutes=refresh_minutes, 

521 model_provider=model_provider, 

522 model=model, 

523 search_strategy=search_strategy, 

524 custom_endpoint=custom_endpoint, 

525 name=name, 

526 folder_id=folder_id, 

527 is_active=is_active, 

528 search_engine=search_engine, 

529 search_iterations=search_iterations, 

530 questions_per_iteration=questions_per_iteration, 

531 ) 

532 

533 except ValueError as e: 

534 return JSONResponse( 

535 {"error": safe_error_message(e, "creating subscription")}, 

536 status_code=400, 

537 ) 

538 except NewsAPIException: 

539 # api.create_subscription() always raises SubscriptionCreationException 

540 # on failure (e.g. the N14 egress-policy rejection, or a wrapped DB 

541 # error) — let the NewsAPIException handler in fastapi_app.py render 

542 # exc.to_dict() (error_code, details) instead of downgrading it to a 

543 # generic {"error": ...} below. 

544 raise 

545 except Exception as e: 

546 return JSONResponse( 

547 {"error": safe_error_message(e, "creating subscription")}, 

548 status_code=500, 

549 ) 

550 

551 

552@router.post("/vote") 

553async def vote_on_news( 

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

555) -> Any: 

556 """ 

557 Submit vote on a news item. 

558 

559 JSON body: 

560 card_id: ID of the news card 

561 vote: "up" or "down" 

562 """ 

563 try: 

564 data, _body_err = await read_json_dict( 

565 request, "simple", "No JSON data provided" 

566 ) 

567 if _body_err is not None: 

568 return _body_err 

569 

570 # Get current user 

571 user_id = username 

572 

573 card_id = data.get("card_id") 

574 vote = data.get("vote") 

575 

576 # Validate 

577 if not all([card_id, vote]): 

578 return JSONResponse( 

579 {"error": "card_id and vote are required"}, status_code=400 

580 ) 

581 

582 # Sync DB work (SQLCipher session + commit) — threadpool, not loop. 

583 return await run_db_sync( 

584 api.submit_feedback, card_id=card_id, user_id=user_id, vote=vote 

585 ) 

586 

587 except ValueError as e: 

588 error_msg = str(e) 

589 if "not found" in error_msg.lower(): 

590 return JSONResponse( 

591 {"error": "Resource not found"}, status_code=404 

592 ) 

593 return JSONResponse( 

594 {"error": safe_error_message(e, "submitting vote")}, 

595 status_code=400, 

596 ) 

597 except Exception as e: 

598 return JSONResponse( 

599 {"error": safe_error_message(e, "submitting vote")}, 

600 status_code=500, 

601 ) 

602 

603 

604@router.post("/feedback/batch") 

605async def get_batch_feedback( 

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

607) -> Any: 

608 """ 

609 Get feedback (votes) for multiple news cards. 

610 JSON body: 

611 card_ids: List of card IDs 

612 """ 

613 try: 

614 data, _body_err = await read_json_dict( 

615 request, "simple", "No JSON data provided" 

616 ) 

617 if _body_err is not None: 

618 return _body_err 

619 card_ids = data.get("card_ids", []) 

620 if not isinstance(card_ids, list) or any( 

621 not isinstance(card_id, str) or not card_id.strip() 

622 for card_id in card_ids 

623 ): 

624 return JSONResponse( 

625 {"error": "card_ids must be a list of non-empty strings"}, 

626 status_code=400, 

627 ) 

628 if len(card_ids) > NEWS_BATCH_FEEDBACK_MAX_CARD_IDS: 

629 return JSONResponse( 

630 { 

631 "error": ( 

632 "card_ids exceeds maximum " 

633 f"({NEWS_BATCH_FEEDBACK_MAX_CARD_IDS})" 

634 ) 

635 }, 

636 status_code=400, 

637 ) 

638 if not card_ids: 

639 return {"votes": {}} 

640 

641 # Get current user 

642 user_id = username 

643 

644 # Sync DB work (SQLCipher session) — threadpool, not loop. 

645 return await run_db_sync( 

646 api.get_votes_for_cards, card_ids=card_ids, user_id=user_id 

647 ) 

648 

649 except ValueError as e: 

650 error_msg = str(e) 

651 if "not found" in error_msg.lower(): 651 ↛ 655line 651 didn't jump to line 655 because the condition on line 651 was always true

652 return JSONResponse( 

653 {"error": "Resource not found"}, status_code=404 

654 ) 

655 return JSONResponse( 

656 {"error": safe_error_message(e, "getting votes")}, 

657 status_code=400, 

658 ) 

659 except Exception as e: 

660 logger.exception("Error getting batch feedback") 

661 return JSONResponse( 

662 {"error": safe_error_message(e, "getting votes")}, 

663 status_code=500, 

664 ) 

665 

666 

667@router.post("/feedback/{card_id}") 

668@_news_feedback_limit 

669async def submit_feedback( 

670 card_id: str, 

671 request: Request, 

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

673) -> Any: 

674 """ 

675 Submit feedback (vote) for a news card. 

676 

677 JSON body: 

678 vote: "up" or "down" 

679 """ 

680 try: 

681 data, _body_err = await read_json_dict( 

682 request, "simple", "No JSON data provided" 

683 ) 

684 if _body_err is not None: 

685 return _body_err 

686 

687 # Get current user 

688 user_id = username 

689 vote = data.get("vote") 

690 

691 # Validate 

692 if not vote: 

693 return JSONResponse({"error": "vote is required"}, status_code=400) 

694 

695 # Sync DB work (SQLCipher session + commit) — threadpool, not loop. 

696 return await run_db_sync( 

697 api.submit_feedback, card_id=card_id, user_id=user_id, vote=vote 

698 ) 

699 

700 except ValueError as e: 

701 error_msg = str(e) 

702 if "not found" in error_msg.lower(): 

703 return JSONResponse( 

704 {"error": "Resource not found"}, status_code=404 

705 ) 

706 if "must be" in error_msg.lower(): 

707 return JSONResponse( 

708 {"error": "Invalid input value"}, status_code=400 

709 ) 

710 return JSONResponse( 

711 {"error": safe_error_message(e, "submitting feedback")}, 

712 status_code=400, 

713 ) 

714 except Exception as e: 

715 return JSONResponse( 

716 {"error": safe_error_message(e, "submitting feedback")}, 

717 status_code=500, 

718 ) 

719 

720 

721@router.post("/research/{card_id}") 

722@_news_research_limit 

723async def research_news_item( 

724 card_id: str, 

725 request: Request, 

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

727) -> Any: 

728 """ 

729 Perform deeper research on a news item. 

730 

731 JSON body: 

732 depth: "quick", "detailed", or "report" (default: "quick") 

733 """ 

734 try: 

735 # Body is OPTIONAL here (`or {}`), so an absent/empty body is valid 

736 # and must stay valid. Only a body that will not PARSE is a 400 -- 

737 # previously it escaped to the generic handler as a 500. 

738 try: 

739 data = await request.json() or {} 

740 except Exception: 

741 return json_body_error("simple", "Request body must be valid JSON") 

742 if not isinstance(data, dict): 742 ↛ 743line 742 didn't jump to line 743 because the condition on line 742 was never true

743 return json_body_error("simple", "Request body must be valid JSON") 

744 depth = data.get("depth", "quick") 

745 

746 # Sync news-api work — threadpool, not loop. 

747 return await run_db_sync(api.research_news_item, card_id, depth) 

748 

749 except NewsAPIException: 

750 # api.research_news_item() currently always raises 

751 # NotImplementedException (status_code=501) — let it carry that 

752 # status through the registered handler instead of downgrading it 

753 # to a generic 500 below. 

754 raise 

755 except Exception as e: 

756 return JSONResponse( 

757 {"error": safe_error_message(e, "researching news item")}, 

758 status_code=500, 

759 ) 

760 

761 

762@router.get("/subscriptions/current") 

763def get_current_user_subscriptions( 

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

765) -> Any: 

766 """Get all subscriptions for current user.""" 

767 try: 

768 # Get current user 

769 user_id = username 

770 

771 # Ensure we have a database session for the user 

772 # This will trigger register_activity 

773 logger.debug(f"Getting news feed for user {user_id}") 

774 

775 # Use the API function 

776 result = api.get_subscriptions(user_id) 

777 if "error" in result: 

778 logger.error( 

779 f"Error getting subscriptions for user {user_id}: {result['error']}" 

780 ) 

781 return JSONResponse( 

782 {"error": "Failed to retrieve subscriptions"}, status_code=500 

783 ) 

784 return result 

785 

786 except NewsAPIException: 

787 # api.get_subscriptions() raises DatabaseAccessException on failure 

788 # — let the NewsAPIException handler in fastapi_app.py render 

789 # exc.to_dict() (error_code, details) instead of downgrading it to 

790 # a generic {"error": ...} below. 

791 raise 

792 except Exception as e: 

793 return JSONResponse( 

794 {"error": safe_error_message(e, "getting subscriptions")}, 

795 status_code=500, 

796 ) 

797 

798 

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

800def get_subscription( 

801 subscription_id: str, 

802 request: Request, 

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

804) -> Any: 

805 """Get a single subscription by ID.""" 

806 try: 

807 # Handle null or invalid subscription IDs 

808 if ( 

809 subscription_id == "null" 

810 or subscription_id == "undefined" 

811 or not subscription_id 

812 ): 

813 return JSONResponse( 

814 {"error": "Invalid subscription ID"}, status_code=400 

815 ) 

816 

817 # api.get_subscription() always returns a subscription dict or 

818 # raises SubscriptionNotFoundException — never a falsy value — so 

819 # there is no "not found" case to handle here beyond the 

820 # except NewsAPIException re-raise below. 

821 return api.get_subscription(subscription_id, username=username) 

822 

823 except NewsAPIException: 

824 # Carries its own status (e.g. 404 for not-found) — let the 

825 # NewsAPIException handler in fastapi_app.py render it instead of 

826 # downgrading it to a generic 500 below. 

827 raise 

828 except Exception as e: 

829 return JSONResponse( 

830 {"error": safe_error_message(e, "getting subscription")}, 

831 status_code=500, 

832 ) 

833 

834 

835@router.put( 

836 "/subscriptions/{subscription_id}", 

837 operation_id="news_update_subscription", 

838) 

839async def update_subscription( 

840 subscription_id: str, 

841 request: Request, 

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

843) -> Any: 

844 """Update a subscription.""" 

845 try: 

846 data, _body_err = await read_json_dict( 

847 request, "simple", "No JSON data provided" 

848 ) 

849 if _body_err is not None: 

850 return _body_err 

851 except Exception: 

852 return JSONResponse({"error": "Invalid JSON data"}, status_code=400) 

853 

854 try: 

855 validation_error = _validate_subscription_payload(data) 

856 if validation_error is not None: 

857 return validation_error 

858 

859 bad_endpoint = await _reject_custom_endpoint_async( 

860 data.get("custom_endpoint") 

861 ) 

862 if bad_endpoint is not None: 

863 return bad_endpoint 

864 

865 # Prepare update data 

866 update_data = {} 

867 

868 # Map fields from request to storage format 

869 field_mapping = { 

870 "query": "query_or_topic", 

871 "name": "name", 

872 "refresh_minutes": "refresh_interval_minutes", 

873 "is_active": "is_active", 

874 "folder_id": "folder_id", 

875 "model_provider": "model_provider", 

876 "model": "model", 

877 "search_strategy": "search_strategy", 

878 "custom_endpoint": "custom_endpoint", 

879 "search_engine": "search_engine", 

880 "search_iterations": "search_iterations", 

881 "questions_per_iteration": "questions_per_iteration", 

882 } 

883 

884 for request_field, storage_field in field_mapping.items(): 

885 if request_field in data: 

886 update_data[storage_field] = data[request_field] 

887 

888 # Update subscription — sync DB work goes to the threadpool. 

889 # api.update_subscription() always returns a {"status": "success", 

890 # "subscription": {...}} dict or raises a NewsAPIException — never a 

891 # dict with an "error" key — so there is no such case to handle 

892 # here beyond the except NewsAPIException re-raise below. 

893 return await run_db_sync( 

894 api.update_subscription, 

895 subscription_id, 

896 update_data, 

897 username=username, 

898 ) 

899 

900 except NewsAPIException: 

901 # Carries its own status (e.g. 404 for not-found) — let the 

902 # NewsAPIException handler in fastapi_app.py render it instead of 

903 # downgrading it to a generic 500 below. 

904 raise 

905 except Exception as e: 

906 return JSONResponse( 

907 {"error": safe_error_message(e, "updating subscription")}, 

908 status_code=500, 

909 ) 

910 

911 

912@router.delete("/subscriptions/{subscription_id}") 

913def delete_subscription( 

914 subscription_id: str, 

915 request: Request, 

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

917) -> Any: 

918 """Delete a subscription.""" 

919 try: 

920 # api.delete_subscription() always returns a truthy status dict or 

921 # raises SubscriptionNotFoundException — never a falsy value — so 

922 # there is no "not found" case to handle here beyond the 

923 # except NewsAPIException re-raise below. 

924 api.delete_subscription(subscription_id, username=username) 

925 return { 

926 "status": "success", 

927 "message": f"Subscription {subscription_id} deleted", 

928 } 

929 

930 except NewsAPIException: 

931 # Carries its own status (e.g. 404 for not-found) — let the 

932 # NewsAPIException handler in fastapi_app.py render it instead of 

933 # downgrading it to a generic 500 below. 

934 raise 

935 except Exception as e: 

936 return JSONResponse( 

937 {"error": safe_error_message(e, "deleting subscription")}, 

938 status_code=500, 

939 ) 

940 

941 

942@router.post("/subscriptions/{subscription_id}/run") 

943def run_subscription_now( 

944 subscription_id: str, 

945 request: Request, 

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

947) -> Any: 

948 """Manually trigger a subscription to run now.""" 

949 try: 

950 from ...news.core.utils import get_local_date_string 

951 from ...news.subscription_runner import ( 

952 advance_refresh_schedule, 

953 build_subscription_request_data, 

954 ) 

955 from ...database.session_context import get_user_db_session 

956 from ...database.models.news import NewsSubscription 

957 from ...settings.manager import SettingsManager 

958 from datetime import datetime, timezone 

959 

960 # Load the subscription from the user's database. Reading the ORM row 

961 # directly (rather than the trimmed api.get_subscriptions() dict, which 

962 # drops model_provider/model/search_strategy/search_engine) ensures the 

963 # manual run honors the subscription's saved model config, matching the 

964 # overdue sweep and the scheduler. 

965 # Read the subscription + build the payload, then release the read 

966 # transaction before the blocking POST below. The per-user encrypted DB 

967 # uses deferred isolation, so a SELECT holds a SHARED lock on the 

968 # SQLCipher file until the transaction ends — and the session is 

969 # request-cached, so it is NOT closed on `with` exit. We therefore 

970 # rollback() explicitly to drop the read lock before the (up to 30s) 

971 # HTTP call, then reopen a short session to advance. 

972 with get_user_db_session(username) as db: 

973 sub = ( 

974 db.query(NewsSubscription) 

975 .filter(NewsSubscription.id == subscription_id) 

976 .first() 

977 ) 

978 if not sub: 

979 return JSONResponse( 

980 {"error": "Subscription not found"}, status_code=404 

981 ) 

982 

983 subscription_pk = sub.id 

984 # Snapshot next_refresh for the post-POST compare-and-set (below): 

985 # a fast-failing run's failure handler may reset next_refresh on the 

986 # worker thread, and the advance must not clobber that. 

987 prev_next_refresh = sub.next_refresh 

988 settings_manager = SettingsManager(db) 

989 current_date = get_local_date_string(settings_manager) 

990 

991 request_data = build_subscription_request_data( 

992 query_template=sub.query_or_topic, 

993 current_date=current_date, 

994 triggered_by="manual", 

995 subscription_id=sub.id, 

996 model_provider=sub.model_provider, 

997 model=sub.model, 

998 search_strategy=sub.search_strategy, 

999 search_engine=sub.search_engine, 

1000 custom_endpoint=sub.custom_endpoint, 

1001 title=sub.name, 

1002 ) 

1003 # End the read transaction so the SHARED lock is released before the 

1004 # blocking POST (exiting the `with` does not — the session is 

1005 # request-cached and not closed on exit). 

1006 db.rollback() 

1007 

1008 # Start the research IN-PROCESS (no loopback HTTP POST). The old 

1009 # loopback built its target URL from request.base_url — i.e. the 

1010 # client-controlled Host header — and sent the caller's own session 

1011 # cookie + CSRF token to it via safe_post(allow_localhost=True, 

1012 # allow_private_ips=True). A spoofed Host turned this into an SSRF / 

1013 # internal-network request oracle. Calling the handler directly 

1014 # removes the network hop (and the SSRF) entirely and matches main's 

1015 # #4834, which replaced the same loopback with an in-process call. 

1016 data = _start_research_in_process(request, request_data, username) 

1017 

1018 if data.get("status") in ("success", "queued"): 

1019 # Advance the schedule so a subscription that was also overdue 

1020 # is not immediately re-run by the scheduler while this run is 

1021 # in flight. If the run later fails, the research failure 

1022 # handler resets next_refresh so the scheduler retries it. 

1023 # Compare-and-set: only advance if next_refresh is unchanged 

1024 # since we read it pre-spawn. A fast-failing run can reset it 

1025 # (worker thread) before we get here; in that case leave the 

1026 # reset in place rather than clobbering it and re-hiding the 

1027 # failed subscription for a full interval. 

1028 with get_user_db_session(username) as db: 

1029 sub = ( 

1030 db.query(NewsSubscription) 

1031 .filter(NewsSubscription.id == subscription_pk) 

1032 .first() 

1033 ) 

1034 if sub and sub.next_refresh == prev_next_refresh: 

1035 advance_refresh_schedule(sub, datetime.now(timezone.utc)) 

1036 db.commit() 

1037 return { 

1038 "status": "success", 

1039 "message": "Research started", 

1040 "research_id": data.get("research_id"), 

1041 "url": f"/progress/{data.get('research_id')}", 

1042 } 

1043 # start_research reports failures under "message"; keep "error" 

1044 # as a fallback for any other shape. Propagate the real status code 

1045 # (400/409/429) the in-process call surfaced, not a flat 500. 

1046 return JSONResponse( 

1047 { 

1048 "error": data.get( 

1049 "message", 

1050 data.get("error", "Failed to start research"), 

1051 ) 

1052 }, 

1053 status_code=data.get("_http_status", 500), 

1054 ) 

1055 

1056 except Exception as e: 

1057 return JSONResponse( 

1058 {"error": safe_error_message(e, "running subscription")}, 

1059 status_code=500, 

1060 ) 

1061 

1062 

1063@router.get("/subscriptions/{subscription_id}/history") 

1064def get_subscription_history( 

1065 subscription_id: str, 

1066 request: Request, 

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

1068) -> Any: 

1069 """Get research history for a subscription.""" 

1070 try: 

1071 if not _is_valid_uuid(subscription_id): 

1072 return JSONResponse( 

1073 {"success": False, "error": "Invalid subscription_id"}, 

1074 status_code=400, 

1075 ) 

1076 with get_user_db_session(username) as _db: 

1077 settings_manager = get_settings_manager(_db, username) 

1078 default_limit = settings_manager.get_setting( 

1079 "news.feed.default_limit" 

1080 ) 

1081 try: 

1082 limit = int(request.query_params.get("limit", default_limit)) 

1083 except (TypeError, ValueError): 

1084 limit = int(default_limit) 

1085 limit = max(1, min(limit, NEWS_FEED_MAX_LIMIT)) 

1086 # api.get_subscription_history() always returns a dict with 

1087 # subscription/history/total_runs, or raises SubscriptionNotFoundException 

1088 # — never a dict with an "error" key — so there is no such case to 

1089 # handle here beyond the except NewsAPIException re-raise below. 

1090 return api.get_subscription_history( 

1091 subscription_id, limit, username=username 

1092 ) 

1093 except NewsAPIException: 

1094 # Carries its own status (e.g. 404 for not-found) — let the 

1095 # NewsAPIException handler in fastapi_app.py render it instead of 

1096 # downgrading it to a generic 500 below. 

1097 raise 

1098 except Exception as e: 

1099 return JSONResponse( 

1100 {"error": safe_error_message(e, "getting subscription history")}, 

1101 status_code=500, 

1102 ) 

1103 

1104 

1105@router.post("/preferences") 

1106@_news_preferences_limit 

1107async def save_preferences( 

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

1109) -> Any: 

1110 """Save user preferences for news.""" 

1111 try: 

1112 data, _body_err = await read_json_dict( 

1113 request, "simple", "No JSON data provided" 

1114 ) 

1115 if _body_err is not None: 

1116 return _body_err 

1117 

1118 # Get current user 

1119 user_id = username 

1120 preferences = data.get("preferences", {}) 

1121 

1122 # Sync DB work (SQLCipher session + commit) — threadpool, not loop. 

1123 return await run_db_sync( 

1124 api.save_news_preferences, user_id, preferences 

1125 ) 

1126 

1127 except NewsAPIException: 

1128 # api.save_news_preferences() currently always raises 

1129 # NotImplementedException (status_code=501) — let it carry that 

1130 # status through the registered handler instead of downgrading it 

1131 # to a generic 500 below. 

1132 raise 

1133 except Exception as e: 

1134 return JSONResponse( 

1135 {"error": safe_error_message(e, "saving preferences")}, 

1136 status_code=500, 

1137 ) 

1138 

1139 

1140@router.get("/categories") 

1141def get_categories( 

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

1143) -> Any: 

1144 """Get news category distribution.""" 

1145 from ...news.exceptions import NotImplementedException 

1146 

1147 try: 

1148 return api.get_news_categories() 

1149 except NotImplementedException: 

1150 # Surface "not yet implemented" as 501 instead of generic 500. The 

1151 # message is a static string — not ``str(e)`` — so this endpoint 

1152 # doesn't echo exception-derived text to the client (CWE-209, 

1153 # CodeQL "Information exposure through an exception"); every other 

1154 # handler in this file already goes through safe_error_message() 

1155 # for the same reason. The exception is still logged server-side. 

1156 logger.exception("get_news_categories: feature not implemented") 

1157 return JSONResponse( 

1158 { 

1159 "error": "This feature is not yet implemented.", 

1160 "error_code": "NOT_IMPLEMENTED", 

1161 }, 

1162 status_code=501, 

1163 ) 

1164 except Exception as e: 

1165 return JSONResponse( 

1166 {"error": safe_error_message(e, "getting categories")}, 

1167 status_code=500, 

1168 ) 

1169 

1170 

1171@router.get("/scheduler/status") 

1172def get_scheduler_status( 

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

1174) -> Any: 

1175 """Get activity-based scheduler status.""" 

1176 try: 

1177 logger.info("Scheduler status endpoint called") 

1178 from ...scheduler.background import get_background_job_scheduler 

1179 

1180 # Get scheduler instance 

1181 scheduler = get_background_job_scheduler() 

1182 show_all = get_env_setting("news.scheduler.allow_api_control", False) 

1183 logger.info( 

1184 f"Scheduler instance obtained: is_running={scheduler.is_running}" 

1185 ) 

1186 

1187 # Build status manually to avoid potential deadlock 

1188 if show_all: 

1189 active_users = ( 

1190 len(scheduler.user_sessions) 

1191 if hasattr(scheduler, "user_sessions") 

1192 else 0 

1193 ) 

1194 else: 

1195 active_users = ( 

1196 1 

1197 if hasattr(scheduler, "user_sessions") 

1198 and username in scheduler.user_sessions 

1199 else 0 

1200 ) 

1201 

1202 status = { 

1203 "scheduler_available": True, # APScheduler is installed and working 

1204 "is_running": scheduler.is_running, 

1205 "config": scheduler.config.copy() 

1206 if hasattr(scheduler, "config") 

1207 else {}, 

1208 "active_users": active_users, 

1209 "total_scheduled_jobs": 0, 

1210 } 

1211 

1212 # Count scheduled jobs 

1213 if hasattr(scheduler, "user_sessions"): 1213 ↛ 1225line 1213 didn't jump to line 1225 because the condition on line 1213 was always true

1214 if show_all: 

1215 total_jobs = sum( 

1216 len(sess.get("scheduled_jobs", set())) 

1217 for sess in scheduler.user_sessions.values() 

1218 ) 

1219 else: 

1220 user_session = scheduler.user_sessions.get(username, {}) 

1221 total_jobs = len(user_session.get("scheduled_jobs", set())) 

1222 status["total_scheduled_jobs"] = total_jobs 

1223 

1224 # Also count actual APScheduler jobs 

1225 if hasattr(scheduler, "scheduler") and scheduler.scheduler: 1225 ↛ 1251line 1225 didn't jump to line 1251 because the condition on line 1225 was always true

1226 try: 

1227 apscheduler_jobs = scheduler.scheduler.get_jobs() 

1228 if not show_all: 

1229 apscheduler_jobs = [ 

1230 j 

1231 for j in apscheduler_jobs 

1232 if _is_job_owned_by_user(j, username, scheduler) 

1233 ] 

1234 status["apscheduler_job_count"] = len(apscheduler_jobs) 

1235 status["apscheduler_jobs"] = [ 

1236 { 

1237 "id": job.id, 

1238 "name": job.name, 

1239 "next_run": job.next_run_time.isoformat() 

1240 if job.next_run_time 

1241 else None, 

1242 } 

1243 for job in apscheduler_jobs[ 

1244 :10 

1245 ] # Limit to first 10 for display 

1246 ] 

1247 except Exception: 

1248 logger.exception("Error getting APScheduler jobs") 

1249 status["apscheduler_job_count"] = 0 

1250 

1251 logger.info(f"Status built: {list(status.keys())}") 

1252 

1253 # Add scheduled_jobs field that JS expects 

1254 status["scheduled_jobs"] = status.get("total_scheduled_jobs", 0) 

1255 

1256 logger.info( 

1257 f"Returning status: is_running={status.get('is_running')}, active_users={status.get('active_users')}" 

1258 ) 

1259 return status 

1260 

1261 except Exception as e: 

1262 return JSONResponse( 

1263 {"error": safe_error_message(e, "getting scheduler status")}, 

1264 status_code=500, 

1265 ) 

1266 

1267 

1268@router.post("/scheduler/start") 

1269def start_scheduler( 

1270 request: Request, 

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

1272 _gate: Annotated[None, Depends(require_scheduler_control)], 

1273) -> Any: 

1274 """Start the subscription scheduler.""" 

1275 try: 

1276 from ...scheduler.background import get_background_job_scheduler 

1277 

1278 # Get scheduler instance 

1279 scheduler = get_background_job_scheduler() 

1280 

1281 if scheduler.is_running: 

1282 return {"message": "Scheduler is already running"} 

1283 

1284 # Start the scheduler 

1285 scheduler.start() 

1286 

1287 # Update app reference 

1288 # Scheduler is a singleton — no need to store on app 

1289 

1290 logger.info("News scheduler started via API") 

1291 return { 

1292 "status": "success", 

1293 "message": "Scheduler started", 

1294 "active_users": len(scheduler.user_sessions), 

1295 } 

1296 

1297 except Exception as e: 

1298 return JSONResponse( 

1299 {"error": safe_error_message(e, "starting scheduler")}, 

1300 status_code=500, 

1301 ) 

1302 

1303 

1304@router.post("/scheduler/stop") 

1305def stop_scheduler( 

1306 request: Request, 

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

1308 _gate: Annotated[None, Depends(require_scheduler_control)], 

1309) -> Any: 

1310 """Stop the subscription scheduler.""" 

1311 try: 

1312 from ...scheduler.background import get_background_job_scheduler 

1313 

1314 if get_background_job_scheduler(): 

1315 scheduler = get_background_job_scheduler() 

1316 if scheduler.is_running: 

1317 scheduler.stop() 

1318 logger.info("News scheduler stopped via API") 

1319 return {"status": "success", "message": "Scheduler stopped"} 

1320 

1321 return {"message": "Scheduler is not running"} 

1322 return JSONResponse( 

1323 {"message": "No scheduler instance found"}, status_code=404 

1324 ) 

1325 

1326 except Exception as e: 

1327 return JSONResponse( 

1328 {"error": safe_error_message(e, "stopping scheduler")}, 

1329 status_code=500, 

1330 ) 

1331 

1332 

1333@router.post("/scheduler/check-now") 

1334def check_subscriptions_now( 

1335 request: Request, 

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

1337 _gate: Annotated[None, Depends(require_scheduler_control)], 

1338) -> Any: 

1339 """Manually trigger subscription checking.""" 

1340 try: 

1341 from ...scheduler.background import get_background_job_scheduler 

1342 

1343 if not get_background_job_scheduler(): 

1344 return JSONResponse( 

1345 {"error": "Scheduler not initialized"}, status_code=503 

1346 ) 

1347 

1348 scheduler = get_background_job_scheduler() 

1349 if not scheduler.is_running: 

1350 return JSONResponse( 

1351 {"error": "Scheduler is not running"}, status_code=503 

1352 ) 

1353 

1354 # Run the check subscriptions task immediately 

1355 scheduler_instance = get_background_job_scheduler() 

1356 

1357 # Get count of due subscriptions 

1358 from ...database.models import NewsSubscription as BaseSubscription 

1359 from datetime import datetime, timedelta, timezone 

1360 

1361 with get_user_db_session(username) as session: 

1362 now = datetime.now(timezone.utc) 

1363 count = ( 

1364 session.query(BaseSubscription) 

1365 .filter(BaseSubscription.due_filter(now)) 

1366 .count() 

1367 ) 

1368 

1369 # Trigger the check asynchronously via APScheduler, matching main: 

1370 # the per-user job id + replace_existing dedups double-clicks, and 

1371 # _wrap_job supplies the worker-side context handling. 

1372 scheduler_instance.scheduler.add_job( 

1373 func=scheduler_instance._wrap_job( 

1374 scheduler_instance._check_user_overdue_subscriptions, 

1375 username=username, 

1376 ), 

1377 args=[username], 

1378 trigger="date", 

1379 run_date=datetime.now(timezone.utc) + timedelta(seconds=1), 

1380 id=f"manual_check_{username}", 

1381 replace_existing=True, 

1382 ) 

1383 

1384 return { 

1385 "status": "success", 

1386 "message": f"Checking {count} due subscriptions", 

1387 "count": count, 

1388 } 

1389 

1390 except Exception as e: 

1391 return JSONResponse( 

1392 {"error": safe_error_message(e, "checking subscriptions")}, 

1393 status_code=500, 

1394 ) 

1395 

1396 

1397@router.post("/scheduler/cleanup-now") 

1398def trigger_cleanup( 

1399 request: Request, 

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

1401 _gate: Annotated[None, Depends(require_scheduler_control)], 

1402) -> Any: 

1403 """Manually trigger cleanup job.""" 

1404 try: 

1405 from ...scheduler.background import get_background_job_scheduler 

1406 from datetime import datetime, UTC, timedelta 

1407 

1408 scheduler = get_background_job_scheduler() 

1409 

1410 if not scheduler.is_running: 

1411 return JSONResponse( 

1412 {"error": "Scheduler is not running"}, status_code=400 

1413 ) 

1414 

1415 # Schedule cleanup to run in 1 second 

1416 # replace_existing=True and _wrap_job both matter here, and both were 

1417 # lost in the port. The job id is fixed and the run_date is 1s out, so 

1418 # a second POST inside that window raises ConflictingIdError, which the 

1419 # blanket except below turns into a 500 -- a double-click on the admin 

1420 # button is enough. main replaced the pending job and returned 200. 

1421 # _wrap_job is inert for a system job (username=None -> nullcontext) 

1422 # but this was the only add_job in the tree without it; the sibling 

1423 # check_subscriptions_now above keeps both and says why. 

1424 scheduler.scheduler.add_job( 

1425 scheduler._wrap_job(scheduler._run_cleanup_with_tracking), 

1426 "date", 

1427 run_date=datetime.now(UTC) + timedelta(seconds=1), 

1428 id="manual_cleanup_trigger", 

1429 replace_existing=True, 

1430 ) 

1431 

1432 return { 

1433 "status": "triggered", 

1434 "message": "Cleanup job will run within seconds", 

1435 } 

1436 

1437 except Exception as e: 

1438 return JSONResponse( 

1439 {"error": safe_error_message(e, "triggering cleanup")}, 

1440 status_code=500, 

1441 ) 

1442 

1443 

1444@router.get("/scheduler/users") 

1445def get_active_users( 

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

1447) -> Any: 

1448 """Get summary of active user sessions.""" 

1449 try: 

1450 from ...scheduler.background import get_background_job_scheduler 

1451 

1452 scheduler = get_background_job_scheduler() 

1453 users_summary = scheduler.get_user_sessions_summary() 

1454 

1455 show_all = get_env_setting("news.scheduler.allow_api_control", False) 

1456 if not show_all: 

1457 users_summary = [ 

1458 u for u in users_summary if u.get("user_id") == username 

1459 ] 

1460 

1461 return {"active_users": len(users_summary), "users": users_summary} 

1462 

1463 except Exception as e: 

1464 return JSONResponse( 

1465 {"error": safe_error_message(e, "getting active users")}, 

1466 status_code=500, 

1467 ) 

1468 

1469 

1470@router.get("/scheduler/stats") 

1471def scheduler_stats( 

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

1473) -> Any: 

1474 """Get scheduler statistics and state.""" 

1475 try: 

1476 from ...scheduler.background import get_background_job_scheduler 

1477 

1478 scheduler = get_background_job_scheduler() 

1479 

1480 # Debug info 

1481 debug_info = { 

1482 "current_user": username, 

1483 "scheduler_running": scheduler.is_running, 

1484 "user_sessions": {}, 

1485 "apscheduler_jobs": [], 

1486 } 

1487 

1488 show_all = get_env_setting("news.scheduler.allow_api_control", False) 

1489 

1490 # Get user session info 

1491 if hasattr(scheduler, "user_sessions"): 1491 ↛ 1510line 1491 didn't jump to line 1510 because the condition on line 1491 was always true

1492 for user, session_info in scheduler.user_sessions.items(): 

1493 if not show_all and user != username: 

1494 continue 

1495 debug_info["user_sessions"][user] = { 

1496 "has_password": bool( 

1497 scheduler._credential_store.retrieve(user) 

1498 ), 

1499 "last_activity": session_info.get( 

1500 "last_activity" 

1501 ).isoformat() 

1502 if session_info.get("last_activity") 

1503 else None, 

1504 "scheduled_jobs_count": len( 

1505 session_info.get("scheduled_jobs", set()) 

1506 ), 

1507 } 

1508 

1509 # Get APScheduler jobs 

1510 if hasattr(scheduler, "scheduler") and scheduler.scheduler: 1510 ↛ 1530line 1510 didn't jump to line 1530 because the condition on line 1510 was always true

1511 jobs = scheduler.scheduler.get_jobs() 

1512 if not show_all: 

1513 jobs = [ 

1514 j 

1515 for j in jobs 

1516 if _is_job_owned_by_user(j, username, scheduler) 

1517 ] 

1518 debug_info["apscheduler_jobs"] = [ 

1519 { 

1520 "id": job.id, 

1521 "name": job.name, 

1522 "next_run": job.next_run_time.isoformat() 

1523 if job.next_run_time 

1524 else None, 

1525 "trigger": str(job.trigger), 

1526 } 

1527 for job in jobs 

1528 ] 

1529 

1530 return debug_info 

1531 

1532 except Exception as e: 

1533 return JSONResponse( 

1534 {"error": safe_error_message(e, "getting scheduler stats")}, 

1535 status_code=500, 

1536 ) 

1537 

1538 

1539@router.post("/check-overdue") 

1540def check_overdue_subscriptions( 

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

1542): 

1543 """Check and run all overdue subscriptions for the current user.""" 

1544 try: 

1545 from ...news.subscription_runner import ( 

1546 advance_refresh_schedule, 

1547 build_subscription_request_data, 

1548 ) 

1549 from ...database.session_context import get_user_db_session 

1550 from ...database.models.news import NewsSubscription 

1551 from datetime import datetime, UTC 

1552 

1553 # Get overdue subscriptions 

1554 overdue_count = 0 

1555 results = [] 

1556 with get_user_db_session(username) as db: 

1557 now = datetime.now(UTC) 

1558 overdue_subs = ( 

1559 db.query(NewsSubscription) 

1560 .filter(NewsSubscription.due_filter(now)) 

1561 .all() 

1562 ) 

1563 

1564 logger.info( 

1565 f"Found {len(overdue_subs)} overdue subscriptions for {username}" 

1566 ) 

1567 

1568 # Get timezone-aware current date using settings 

1569 from ...news.core.utils import get_local_date_string 

1570 from ...settings.manager import SettingsManager 

1571 

1572 settings_manager = SettingsManager(db) 

1573 current_date = get_local_date_string(settings_manager) 

1574 

1575 for sub in overdue_subs: 

1576 # Capture identity up front as plain strings. This loop shares 

1577 # one DB session across every start_research call, and 

1578 # start_research's error path does not roll back — so a failed 

1579 # run can leave the session in a PendingRollbackError state. 

1580 # Reading sub.* again in an error branch would then raise (the 

1581 # row was expired by an earlier commit), collapsing the whole 

1582 # sweep. Snapshotting here keeps the error branches session-free. 

1583 sub_id = str(sub.id) 

1584 sub_label = sub.name or sub.query_or_topic[:50] 

1585 try: 

1586 # Run the subscription using the same pattern as run_subscription_now 

1587 logger.info( 

1588 f"Running overdue subscription: {sub_label[:30]}" 

1589 ) 

1590 

1591 # Snapshot for the post-run compare-and-set (see below). 

1592 prev_next_refresh = sub.next_refresh 

1593 

1594 request_data = build_subscription_request_data( 

1595 query_template=sub.query_or_topic, 

1596 current_date=current_date, 

1597 triggered_by="overdue_check", 

1598 subscription_id=sub.id, 

1599 model_provider=sub.model_provider, 

1600 model=sub.model, 

1601 search_strategy=sub.search_strategy, 

1602 search_engine=sub.search_engine, 

1603 custom_endpoint=sub.custom_endpoint, 

1604 title=sub.name, 

1605 ) 

1606 

1607 # Start the research IN-PROCESS — see the run-now handler 

1608 # above. The old loopback built its URL from the 

1609 # client-controlled Host header and forwarded the caller's 

1610 # cookie/CSRF via safe_post(allow_localhost/private=True): 

1611 # an SSRF whose raw response body was reflected back to the 

1612 # client in the `error` field below (an internal-response 

1613 # oracle). The in-process call removes both. 

1614 result = _start_research_in_process( 

1615 request, request_data, username 

1616 ) 

1617 

1618 if result.get("status") in ("success", "queued"): 

1619 overdue_count += 1 

1620 

1621 # Update subscription's last/next refresh times. 

1622 # Compare-and-set: re-read and skip the advance if a 

1623 # fast-failing run already reset next_refresh (worker 

1624 # thread), so we don't clobber the reset and re-hide it. 

1625 db.refresh(sub) 

1626 if sub.next_refresh == prev_next_refresh: 

1627 advance_refresh_schedule(sub, datetime.now(UTC)) 

1628 db.commit() 

1629 

1630 results.append( 

1631 { 

1632 "id": sub_id, 

1633 "name": sub_label, 

1634 "research_id": result.get("research_id"), 

1635 } 

1636 ) 

1637 else: 

1638 # start_research failed and may have left the shared 

1639 # session dirty; reset it so the next subscription runs. 

1640 db.rollback() 

1641 results.append( 

1642 { 

1643 "id": sub_id, 

1644 "name": sub_label, 

1645 # start_research reports failures under 

1646 # "message"; keep "error" as a fallback for 

1647 # any other shape. 

1648 "error": result.get( 

1649 "message", 

1650 result.get( 

1651 "error", "Failed to start research" 

1652 ), 

1653 ), 

1654 } 

1655 ) 

1656 except Exception as e: 

1657 # Recover the shared session (a failed start_research commit 

1658 # can leave it in a PendingRollbackError state) so the 

1659 # remaining overdue subscriptions in the sweep still run. 

1660 db.rollback() 

1661 logger.exception(f"Error running subscription {sub_id}") 

1662 results.append( 

1663 { 

1664 "id": sub_id, 

1665 "name": sub_label, 

1666 "error": safe_error_message( 

1667 e, "running subscription" 

1668 ), 

1669 } 

1670 ) 

1671 

1672 return { 

1673 "status": "success", 

1674 "overdue_found": len(overdue_subs), 

1675 "started": overdue_count, 

1676 "results": results, 

1677 } 

1678 

1679 except Exception as e: 

1680 return JSONResponse( 

1681 {"error": safe_error_message(e, "checking overdue subscriptions")}, 

1682 status_code=500, 

1683 ) 

1684 

1685 

1686# Folder and subscription management routes 

1687@router.get("/subscription/folders") 

1688def get_folders( 

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

1690): 

1691 """Get all folders for the current user""" 

1692 try: 

1693 user_id = username 

1694 

1695 with get_user_db_session(username) as session: 

1696 manager = FolderManager(session) 

1697 folders = manager.get_user_folders(user_id) 

1698 

1699 return [folder.to_dict() for folder in folders] 

1700 

1701 except Exception as e: 

1702 return JSONResponse( 

1703 {"error": safe_error_message(e, "getting folders")}, 

1704 status_code=500, 

1705 ) 

1706 

1707 

1708@router.post("/subscription/folders") 

1709async def create_folder( 

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

1711): 

1712 """Create a new folder""" 

1713 data, _body_err = await read_json_dict( 

1714 request, "simple", "Request body must be valid JSON" 

1715 ) 

1716 if _body_err is not None: 

1717 return _body_err 

1718 

1719 def _impl(): 

1720 try: 

1721 if not data.get("name"): 

1722 return JSONResponse( 

1723 {"error": "Folder name is required"}, status_code=400 

1724 ) 

1725 

1726 with get_user_db_session(username) as session: 

1727 manager = FolderManager(session) 

1728 

1729 # Check if folder already exists 

1730 existing = ( 

1731 session.query(SubscriptionFolder) 

1732 .filter_by(name=data["name"]) 

1733 .first() 

1734 ) 

1735 if existing: 

1736 return JSONResponse( 

1737 {"error": "Folder already exists"}, status_code=409 

1738 ) 

1739 

1740 folder = manager.create_folder( 

1741 name=data["name"], 

1742 description=data.get("description"), 

1743 ) 

1744 

1745 return JSONResponse(folder.to_dict(), status_code=201) 

1746 

1747 except Exception as e: 

1748 return JSONResponse( 

1749 {"error": safe_error_message(e, "creating folder")}, 

1750 status_code=500, 

1751 ) 

1752 

1753 return await run_db_sync(_impl) 

1754 

1755 

1756@router.put("/subscription/folders/{folder_id}") 

1757async def update_folder( 

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

1759): 

1760 """Update a folder""" 

1761 data, _body_err = await read_json_dict( 

1762 request, "simple", "Request body must be valid JSON" 

1763 ) 

1764 if _body_err is not None: 

1765 return _body_err 

1766 

1767 def _impl(): 

1768 try: 

1769 with get_user_db_session(username) as session: 

1770 manager = FolderManager(session) 

1771 folder = manager.update_folder(folder_id, **data) 

1772 

1773 if not folder: 

1774 return JSONResponse( 

1775 {"error": "Folder not found"}, status_code=404 

1776 ) 

1777 

1778 return folder.to_dict() 

1779 

1780 except Exception as e: 

1781 return JSONResponse( 

1782 {"error": safe_error_message(e, "updating folder")}, 

1783 status_code=500, 

1784 ) 

1785 

1786 return await run_db_sync(_impl) 

1787 

1788 

1789@router.delete("/subscription/folders/{folder_id}") 

1790def delete_folder( 

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

1792): 

1793 """Delete a folder""" 

1794 try: 

1795 move_to = request.query_params.get("move_to") 

1796 

1797 with get_user_db_session(username) as session: 

1798 manager = FolderManager(session) 

1799 success = manager.delete_folder(folder_id, move_to) 

1800 

1801 if not success: 

1802 return JSONResponse( 

1803 {"error": "Folder not found"}, status_code=404 

1804 ) 

1805 

1806 return {"status": "deleted"} 

1807 

1808 except Exception as e: 

1809 return JSONResponse( 

1810 {"error": safe_error_message(e, "deleting folder")}, 

1811 status_code=500, 

1812 ) 

1813 

1814 

1815@router.get("/subscription/subscriptions/organized") 

1816def get_subscriptions_organized( 

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

1818): 

1819 """Get subscriptions organized by folder""" 

1820 try: 

1821 user_id = username 

1822 

1823 with get_user_db_session(username) as session: 

1824 manager = FolderManager(session) 

1825 organized = manager.get_subscriptions_by_folder(user_id) 

1826 

1827 # get_subscriptions_by_folder already returns JSON-friendly dicts 

1828 # ({"folders": [{"folder": {...}, "subscriptions": [...]}, ...], 

1829 # "uncategorized": [...]}). The previous code called .to_dict() on 

1830 # those plain dicts (AttributeError -> HTTP 500). Flatten into the 

1831 # {folder_name: [subscription, ...]} map the subscriptions UI 

1832 # consumes (Object.values(...) for the "all" view, keyed lookup per 

1833 # folder), with ungrouped subscriptions under "uncategorized". 

1834 result = {} 

1835 for entry in organized.get("folders", []): 

1836 folder_name = entry["folder"].get("name") or entry[ 

1837 "folder" 

1838 ].get("id") 

1839 result[folder_name] = entry["subscriptions"] 

1840 # Merge (don't overwrite) so a user folder literally named 

1841 # "uncategorized" doesn't have its subscriptions dropped by the 

1842 # ungrouped bucket. In the normal case this just sets the key. 

1843 result.setdefault("uncategorized", []).extend( 

1844 organized.get("uncategorized", []) 

1845 ) 

1846 

1847 return result 

1848 

1849 except Exception as e: 

1850 return JSONResponse( 

1851 {"error": safe_error_message(e, "getting organized subscriptions")}, 

1852 status_code=500, 

1853 ) 

1854 

1855 

1856@router.put("/subscription/subscriptions/{subscription_id}") 

1857async def update_subscription_folder( 

1858 request: Request, 

1859 subscription_id, 

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

1861): 

1862 """Update a subscription (mainly for folder assignment)""" 

1863 data, _body_err = await read_json_dict( 

1864 request, "simple", "Request body must be valid JSON" 

1865 ) 

1866 if _body_err is not None: 

1867 return _body_err 

1868 validation_error = _validate_subscription_payload( 

1869 data, 

1870 query_field="query_or_topic", 

1871 refresh_field="refresh_interval_minutes", 

1872 ) 

1873 if validation_error is not None: 

1874 return validation_error 

1875 # ``_update_subscription_folder_sync``'s blind setattr loop writes any 

1876 # matching column, so ``custom_endpoint`` reaches storage here too -- a 

1877 # third write path alongside create_subscription / update_subscription. 

1878 # Validate it at the boundary, before the sync helper's unredacted log 

1879 # line, so this route cannot persist an SSRF endpoint its siblings 

1880 # reject. Ported from #5603, which closed the same gap on the Flask 

1881 # blueprint this router replaced. 

1882 if "custom_endpoint" in data: 

1883 bad_endpoint = await _reject_custom_endpoint_async( 

1884 data.get("custom_endpoint") 

1885 ) 

1886 if bad_endpoint is not None: 

1887 return bad_endpoint 

1888 return await run_db_sync( 

1889 _update_subscription_folder_sync, data, subscription_id, username 

1890 ) 

1891 

1892 

1893def _update_subscription_folder_sync(data, subscription_id, username): 

1894 try: 

1895 validation_error = _validate_subscription_payload( 

1896 data, 

1897 query_field="query_or_topic", 

1898 refresh_field="refresh_interval_minutes", 

1899 ) 

1900 if validation_error is not None: 

1901 return validation_error 

1902 

1903 logger.info( 

1904 f"Updating subscription {subscription_id} with data: {data}" 

1905 ) 

1906 

1907 with get_user_db_session(username) as session: 

1908 # Manually handle the update to ensure next_refresh is recalculated 

1909 from ...database.models.news import ( 

1910 NewsSubscription as BaseSubscription, 

1911 ) 

1912 from datetime import datetime, timedelta, timezone 

1913 

1914 sub = ( 

1915 session.query(BaseSubscription) 

1916 .filter_by(id=subscription_id) 

1917 .first() 

1918 ) 

1919 if not sub: 

1920 return JSONResponse( 

1921 {"error": "Subscription not found"}, status_code=404 

1922 ) 

1923 

1924 # `status` is the single source of truth for active/paused (the 

1925 # scheduler keys on it, not the legacy is_active column). Translate 

1926 # an is_active toggle into status and keep both out of the blind 

1927 # setattr loop below -- otherwise a body like {"is_active": false} 

1928 # would flip only the legacy column while status stayed "active" 

1929 # and the scheduler would keep running the subscription. Mirrors 

1930 # api.update_subscription's translation. 

1931 if "is_active" in data: 

1932 sub.status = "active" if data["is_active"] else "paused" 

1933 if "status" in data: 

1934 sub.status = data["status"] 

1935 

1936 # Update remaining fields 

1937 for key, value in data.items(): 

1938 if hasattr(sub, key) and key not in [ 

1939 "id", 

1940 "user_id", 

1941 "created_at", 

1942 "is_active", 

1943 "status", 

1944 ]: 

1945 setattr(sub, key, value) 

1946 

1947 # Recalculate next_refresh if refresh_interval_minutes changed 

1948 if "refresh_interval_minutes" in data: 

1949 new_minutes = data["refresh_interval_minutes"] 

1950 if sub.last_refresh: 

1951 sub.next_refresh = sub.last_refresh + timedelta( 

1952 minutes=new_minutes 

1953 ) 

1954 else: 

1955 sub.next_refresh = datetime.now(timezone.utc) + timedelta( 

1956 minutes=new_minutes 

1957 ) 

1958 logger.info(f"Recalculated next_refresh: {sub.next_refresh}") 

1959 

1960 sub.updated_at = datetime.now(timezone.utc) 

1961 session.commit() 

1962 

1963 # NewsSubscription has no to_dict(); serialize the fields the UI 

1964 # needs explicitly. is_active is derived from status (the source of 

1965 # truth) so the response stays consistent with the toggle above. 

1966 result = { 

1967 "id": sub.id, 

1968 "name": sub.name, 

1969 "status": sub.status, 

1970 "is_active": sub.status == "active", 

1971 "folder_id": sub.folder_id, 

1972 "refresh_interval_minutes": sub.refresh_interval_minutes, 

1973 "next_refresh": sub.next_refresh.isoformat() 

1974 if sub.next_refresh 

1975 else None, 

1976 "last_refresh": sub.last_refresh.isoformat() 

1977 if sub.last_refresh 

1978 else None, 

1979 } 

1980 logger.info( 

1981 f"Updated subscription result: refresh_interval_minutes={result.get('refresh_interval_minutes')}, next_refresh={result.get('next_refresh')}" 

1982 ) 

1983 return result 

1984 

1985 except Exception as e: 

1986 return JSONResponse( 

1987 {"error": safe_error_message(e, "updating subscription")}, 

1988 status_code=500, 

1989 ) 

1990 

1991 

1992@router.get("/subscription/stats") 

1993def get_subscription_stats( 

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

1995): 

1996 """Get subscription statistics""" 

1997 try: 

1998 user_id = username 

1999 

2000 with get_user_db_session(username) as session: 

2001 manager = FolderManager(session) 

2002 return manager.get_subscription_stats(user_id) 

2003 

2004 except Exception as e: 

2005 return JSONResponse( 

2006 {"error": safe_error_message(e, "getting stats")}, 

2007 status_code=500, 

2008 ) 

2009 

2010 

2011@router.get("/search-history") 

2012def get_search_history( 

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

2014): 

2015 """Get search history for current user.""" 

2016 try: 

2017 # Get search history from user's encrypted database 

2018 from ...database.session_context import get_user_db_session 

2019 from ...database.models import UserNewsSearchHistory 

2020 

2021 password = None 

2022 

2023 with get_user_db_session(username, password) as db_session: 

2024 history = ( 

2025 db_session.query(UserNewsSearchHistory) 

2026 .order_by(UserNewsSearchHistory.created_at.desc()) 

2027 .limit(20) 

2028 .all() 

2029 ) 

2030 

2031 return {"search_history": [item.to_dict() for item in history]} 

2032 

2033 except Exception as e: 

2034 return JSONResponse( 

2035 {"error": safe_error_message(e, "getting search history")}, 

2036 status_code=500, 

2037 ) 

2038 

2039 

2040@router.post("/search-history") 

2041async def add_search_history( 

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

2043): 

2044 """Add a search to the history.""" 

2045 data = await request.json() 

2046 

2047 def _impl(): 

2048 try: 

2049 # `data` may be any JSON value here (a bare list, string, or 

2050 # number is valid JSON but not a dict) — check the type before 

2051 # calling dict-only methods on it, else a truthy non-dict body 

2052 # raises AttributeError and lands in the `except Exception` 

2053 # below as a 500 instead of the 400 a malformed request should 

2054 # get. 

2055 logger.info( 

2056 "add_search_history received data keys: {}", 

2057 list(data.keys()) if isinstance(data, dict) else "None", 

2058 ) 

2059 if not isinstance(data, dict) or not data.get("query"): 

2060 logger.warning("Invalid search history data: missing query") 

2061 return JSONResponse( 

2062 {"error": "query is required"}, status_code=400 

2063 ) 

2064 

2065 # Add to user's encrypted database 

2066 from ...database.session_context import get_user_db_session 

2067 from ...database.models import UserNewsSearchHistory 

2068 

2069 password = None 

2070 

2071 with get_user_db_session(username, password) as db_session: 

2072 search_history = UserNewsSearchHistory( 

2073 query=data["query"], 

2074 search_type=data.get("type", "filter"), 

2075 result_count=data.get("resultCount", 0), 

2076 ) 

2077 db_session.add(search_history) 

2078 db_session.commit() 

2079 

2080 return {"status": "success", "id": search_history.id} 

2081 

2082 except Exception as e: 

2083 logger.exception("Error adding search history") 

2084 return JSONResponse( 

2085 {"error": safe_error_message(e, "adding search history")}, 

2086 status_code=500, 

2087 ) 

2088 

2089 return await run_db_sync(_impl) 

2090 

2091 

2092@router.delete("/search-history") 

2093def clear_search_history( 

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

2095): 

2096 """Clear all search history for current user.""" 

2097 try: 

2098 # Clear from user's encrypted database 

2099 from ...database.session_context import get_user_db_session 

2100 from ...database.models import UserNewsSearchHistory 

2101 

2102 password = None 

2103 

2104 with get_user_db_session(username, password) as db_session: 

2105 db_session.query(UserNewsSearchHistory).delete() 

2106 db_session.commit() 

2107 

2108 return {"status": "success"} 

2109 

2110 except Exception as e: 

2111 return JSONResponse( 

2112 {"error": safe_error_message(e, "clearing search history")}, 

2113 status_code=500, 

2114 )