Coverage for src/local_deep_research/news/api.py: 94%
424 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"""
2Direct API functions for news system.
3These functions can be called directly by scheduler or wrapped by Flask endpoints.
4"""
6from typing import Any, Callable, Dict, Optional
7from datetime import datetime, timezone, UTC
8from loguru import logger
9from sqlalchemy.orm import Session
10import json
12from ..constants import ResearchStatus
13from .constants import validate_refresh_interval
14from ..llm.providers.base import normalize_provider
15from ..utilities.sql_utils import escape_like
16from ..security.log_sanitizer import redact_and_bound_for_log
17from ..settings.exceptions import SettingsValueError
18from .exceptions import (
19 InvalidLimitException,
20 SubscriptionNotFoundException,
21 SubscriptionCreationException,
22 SubscriptionUpdateException,
23 SubscriptionDeletionException,
24 DatabaseAccessException,
25 NewsFeedGenerationException,
26 NotImplementedException,
27 NewsAPIException,
28)
29from ..utilities.request_context import (
30 get_current_session_id,
31 get_current_username,
32)
33# Removed welcome feed import - no placeholders
34# get_db_setting not available in merged codebase
36# Generic detail surfaced to API clients in place of a raw exception string.
37# These messages are returned to the client via NewsAPIException.to_dict(), so
38# echoing str(e) would leak DB/driver internals (SQL, schema, paths) to callers
39# on shared/multi-user deployments (CWE-209). The real cause is always captured
40# server-side by the adjacent logger.exception(...) for diagnosis.
41_GENERIC_ERROR_DETAIL = "an internal error occurred"
44def _lazy_log_preview(value: Any, max_length: int) -> Callable[[], str]:
45 """A ``logger.opt(lazy=True)`` argument: the redacted, bounded value.
47 Redaction runs before the cut (#6938), and it only runs when a sink
48 takes the record's level, so a per-row DEBUG line costs nothing at the
49 default INFO level however long the stored value is.
50 """
51 return lambda: redact_and_bound_for_log(str(value), max_length)
54def _notify_scheduler_about_subscription_change(
55 action: str, user_id: Optional[str] = None
56):
57 """
58 Notify the scheduler about subscription changes.
60 Args:
61 action: The action performed (created, updated, deleted)
62 user_id: Optional user_id to use as fallback for username
63 """
64 try:
65 from ..scheduler.background import get_background_job_scheduler
67 scheduler = get_background_job_scheduler()
68 if scheduler.is_running:
69 # Get username, with optional fallback to user_id
70 username = get_current_username()
71 if not username and user_id:
72 username = user_id
74 # Get password from session password store
75 from ..database.session_passwords import session_password_store
77 session_id = get_current_session_id()
78 password = None
79 if session_id and username:
80 password = session_password_store.get_session_password(
81 username, session_id
82 )
84 if password and username:
85 # Update scheduler to reschedule subscriptions
86 scheduler.update_user_info(username, password)
87 logger.info(
88 f"Scheduler notified about {action} subscription for {username}"
89 )
90 else:
91 logger.warning(
92 f"Could not notify scheduler - no password available{' for ' + username if username else ''}"
93 )
94 except Exception:
95 logger.exception(
96 f"Could not notify scheduler about {action} subscription"
97 )
100def get_news_feed(
101 user_id: str = "anonymous",
102 limit: int = 20,
103 use_cache: bool = True,
104 focus: Optional[str] = None,
105 search_strategy: Optional[str] = None,
106 subscription_id: Optional[str] = None,
107) -> Dict[str, Any]:
108 """
109 Get personalized news feed by pulling from news_items table first, then research history.
111 Args:
112 user_id: User identifier
113 limit: Maximum number of cards to return
114 use_cache: Whether to use cached news
115 focus: Optional focus area for news
116 search_strategy: Override default recommendation strategy
118 Returns:
119 Dictionary with news items and metadata. Each item's ``findings``
120 field is the answer-only report content (post chat-mode-v2 refactor,
121 #3665 Fix B); structured top-N source links are in the separate
122 ``links`` array, not embedded in ``findings``.
123 """
124 # Validate limit - allow any positive number
125 if limit < 1:
126 raise InvalidLimitException(limit)
128 try:
129 logger.info(
130 f"get_news_feed called with user_id={user_id}, limit={limit}"
131 )
133 # News is always enabled for now - per-user settings will be handled later
134 # if not get_db_setting("news.enabled", True):
135 # return {"error": "News system is disabled", "news_items": []}
137 # Import database functions
138 from ..database.session_context import get_user_db_session
139 from ..database.models import ResearchHistory
140 from ..text_optimization.citation_formatter import (
141 strip_appended_sources_sentinel,
142 )
144 news_items: list[dict[str, Any]] = []
145 remaining_limit = limit
147 # Query research history from user's database for news items
148 logger.info("Getting news items from research history")
149 try:
150 # Use the user_id provided to the function
151 with get_user_db_session(user_id) as db_session:
152 # Build query using ORM
153 query = db_session.query(ResearchHistory).filter(
154 ResearchHistory.status == ResearchStatus.COMPLETED
155 )
157 # Filter by subscription if provided
158 if subscription_id and subscription_id != "all":
159 # Use JSON containment for PostgreSQL or LIKE for SQLite.
160 # Note: research_meta is serialized via json.dumps which
161 # emits a space after the colon, so the LIKE pattern must
162 # include that space too — otherwise the filter silently
163 # matches zero rows. Mirrors the patterns used in
164 # get_subscriptions and get_subscription_history below.
165 query = query.filter(
166 ResearchHistory.research_meta.like(
167 f'%"subscription_id": "{escape_like(subscription_id)}"%',
168 escape="\\",
169 )
170 )
172 # Order by creation date and limit
173 results = (
174 query.order_by(ResearchHistory.created_at.desc())
175 .limit(remaining_limit * 2)
176 .all()
177 )
179 # Convert ORM objects to dictionaries for compatibility
180 results = [
181 {
182 "id": r.id,
183 "uuid_id": r.id, # In ResearchHistory, id is the UUID
184 "query": r.query,
185 "title": r.title
186 if hasattr(r, "title")
187 else None, # Include title field if exists
188 # created_at is NOT NULL (set to isoformat() on every
189 # insert), so it's always a usable timestamp string.
190 "created_at": r.created_at,
191 "completed_at": r.completed_at
192 if r.completed_at
193 else None,
194 "duration_seconds": r.duration_seconds
195 if hasattr(r, "duration_seconds")
196 else None,
197 "report_path": r.report_path
198 if hasattr(r, "report_path")
199 else None,
200 "report_content": r.report_content
201 if hasattr(r, "report_content")
202 else None, # Include database content
203 "research_meta": r.research_meta,
204 "status": r.status,
205 }
206 for r in results
207 ]
209 # Source links used to be parsed out of report_content via
210 # regex over `URL:` lines (when report_content held the
211 # inline ## Sources block). Now sources live in the
212 # research_resources table — fetch top-N for every row in
213 # ONE batched query (avoids N+1 in the loop below).
214 from ..web.services.report_assembly_service import (
215 get_research_source_links_batch,
216 )
218 research_ids_for_links = [r["id"] for r in results]
219 links_by_research_id = get_research_source_links_batch(
220 research_ids_for_links, db_session, limit=3
221 )
223 logger.info(f"Database returned {len(results)} research items")
224 if results and len(results) > 0:
225 logger.info(f"First row keys: {list(results[0].keys())}")
226 # Log first few items' metadata
227 for i, row in enumerate(results[:3]):
228 # (#6938: the query is redacted before it is cut. A
229 # stored query can be tens of KiB and redaction costs
230 # about 1 us per character, so these per-row lines are
231 # DEBUG and lazy: nothing is computed unless a sink
232 # takes DEBUG records.)
233 logger.opt(lazy=True).debug(
234 "Item {}: query='{}', has meta={}",
235 lambda i=i: i,
236 _lazy_log_preview(row["query"], 50),
237 lambda row=row: bool(row.get("research_meta")),
238 )
240 # Process results to find news items
241 processed_count = 0
242 error_count = 0
244 for row in results:
245 try:
246 # Parse metadata
247 metadata = {}
248 if row.get("research_meta"):
249 try:
250 # Handle both dict and string formats
251 if isinstance(row["research_meta"], dict):
252 metadata = row["research_meta"]
253 else:
254 metadata = json.loads(row["research_meta"])
255 except (json.JSONDecodeError, TypeError):
256 logger.exception("Error parsing metadata")
257 metadata = {}
259 # Check if this has news metadata (generated_headline or generated_topics)
260 # or if it's a news-related query
261 has_news_metadata = (
262 metadata.get("generated_headline") is not None
263 or metadata.get("generated_topics") is not None
264 )
266 query_lower = row["query"].lower()
267 is_news_query = (
268 has_news_metadata
269 or metadata.get("is_news_search")
270 or metadata.get("search_type") == "news_analysis"
271 or "breaking news" in query_lower
272 or "news stories" in query_lower
273 or (
274 "today" in query_lower
275 and (
276 "news" in query_lower
277 or "breaking" in query_lower
278 )
279 )
280 or "latest news" in query_lower
281 )
283 # Log the decision for first few items
284 # (#6938: DEBUG and lazy, see the first-rows log above;
285 # error_count is usually 0, so this runs on every row.)
286 if processed_count < 3 or error_count < 3: 286 ↛ 299line 286 didn't jump to line 299 because the condition on line 286 was always true
287 logger.opt(lazy=True).debug(
288 "Item check: query='{}', is_news_search={}, "
289 "has_news_metadata={}, is_news_query={}",
290 _lazy_log_preview(row["query"], 30),
291 lambda metadata=metadata: metadata.get(
292 "is_news_search"
293 ),
294 lambda v=has_news_metadata: v,
295 lambda v=is_news_query: v,
296 )
298 # Only show items that have news metadata or are news queries
299 if is_news_query:
300 processed_count += 1
301 logger.opt(lazy=True).debug(
302 "Processing research item #{}: {}",
303 lambda n=processed_count: n,
304 _lazy_log_preview(row["query"], 50),
305 )
307 # Always use database content
308 findings = ""
309 summary = ""
310 report_content_db = row.get(
311 "report_content"
312 ) # Get database content
314 # Use database content. A detailed report saved
315 # in NO_HYPERLINKS mode, or after the citation
316 # formatter raised, before the save site removed
317 # it still carries the report generator's
318 # appended-sources sentinel, an HTML comment the
319 # card's renderMarkdown() would show as text.
320 content = strip_appended_sources_sentinel(
321 report_content_db
322 )
323 if content:
324 logger.debug(
325 f"Using database content for research {row['id']}"
326 )
328 # Process database content
329 lines = content.split("\n") if content else []
330 # `findings` is the answer-only report_content
331 # after the chat-mode-v2 refactor (#3665 Fix B,
332 # intentional). The legacy answer + ## Sources
333 # blob is gone: structured top-N source URLs live
334 # in the separate `links` array below, so snippet
335 # extraction is cleaner without Sources headers in
336 # the substrate.
337 findings = content
338 # Extract summary from first non-empty line
339 for line in lines: 339 ↛ 349line 339 didn't jump to line 349 because the loop on line 339 didn't complete
340 if line.strip() and not line.startswith("#"):
341 summary = line.strip()
342 break
343 else:
344 logger.debug(
345 f"No database content for research {row['id']}"
346 )
348 # Use stored headline/topics if available, otherwise generate
349 original_query = row["query"]
351 # Check for headline - first try database title, then metadata
352 # What the "Added news item" line logs: the headline,
353 # or the whole query when the headline is cut from
354 # it (#6938: redacted before it is cut).
355 headline_from_query = False
356 headline = row.get("title") or metadata.get(
357 "generated_headline"
358 )
360 # For subscription results, generate headline from query if needed
361 if not headline and metadata.get("is_news_search"):
362 # Use subscription name or query as headline
363 subscription_name = metadata.get(
364 "subscription_name"
365 )
366 if subscription_name:
367 headline = f"News Update: {subscription_name}"
368 else:
369 # Generate headline from query
370 headline = f"News: {row['query'][:60]}..."
371 headline_from_query = True
373 # Skip items without meaningful headlines or that are incomplete
374 if (
375 not headline
376 or headline == "[No headline available]"
377 ):
378 logger.debug(
379 f"Skipping item without headline: {row['id']}"
380 )
381 continue
383 # Skip items that are still in progress or suspended
384 if row["status"] in (
385 ResearchStatus.IN_PROGRESS,
386 ResearchStatus.SUSPENDED,
387 ):
388 logger.debug(
389 f"Skipping incomplete item: {row['id']} (status: {row['status']})"
390 )
391 continue
393 # Skip items without content (neither file nor database)
394 if not content:
395 logger.debug(
396 f"Skipping item without content: {row['id']}"
397 )
398 continue
400 # Use ID properly, preferring uuid_id
401 research_id = row.get("uuid_id") or str(row["id"])
403 # Use stored category and topics - no defaults
404 category = metadata.get("category")
405 if not category: 405 ↛ 408line 405 didn't jump to line 408 because the condition on line 405 was always true
406 category = "[Uncategorized]"
408 topics = metadata.get("generated_topics")
409 if not topics: 409 ↛ 415line 409 didn't jump to line 415 because the condition on line 409 was always true
410 topics = ["[No topics]"]
412 # Top-N links pulled from research_resources via
413 # the batch fetch above (no per-row DB query, no
414 # text parsing of report_content).
415 links = links_by_research_id.get(row["id"], [])
417 # Create news item from research
418 news_item = {
419 "id": f"news-{research_id}",
420 "headline": headline,
421 "category": category,
422 "summary": summary
423 or f"Research analysis for: {headline[:100]}",
424 "findings": findings,
425 "impact_score": metadata.get(
426 "impact_score", 0
427 ), # 0 indicates missing
428 "time_ago": _format_time_ago(row["created_at"]),
429 "upvotes": metadata.get("upvotes", 0),
430 "downvotes": metadata.get("downvotes", 0),
431 "source_url": f"/results/{research_id}",
432 "topics": topics, # Use generated topics
433 "links": links, # Add extracted links
434 "research_id": research_id,
435 "created_at": row["created_at"],
436 "duration_seconds": row.get("duration_seconds", 0),
437 "original_query": original_query, # Keep original query for reference
438 "is_news": metadata.get(
439 "is_news_search", False
440 ), # Flag for news searches
441 "news_date": metadata.get(
442 "news_date"
443 ), # If specific date for news
444 "news_source": metadata.get(
445 "news_source"
446 ), # If from specific source
447 "priority": metadata.get(
448 "priority", "normal"
449 ), # Priority level
450 }
452 news_items.append(news_item)
453 # (#6938: DEBUG and lazy, see the first-rows log.)
454 logger.opt(lazy=True).debug(
455 "Added news item: {}",
456 _lazy_log_preview(
457 "News: " + str(row["query"])
458 if headline_from_query
459 else headline,
460 50,
461 ),
462 )
464 if len(news_items) >= limit:
465 break
467 except Exception:
468 error_count += 1
469 logger.exception(
470 "Error processing research item with query: {}",
471 redact_and_bound_for_log(
472 str(row.get("query", "UNKNOWN")), 100
473 ),
474 )
475 continue
477 logger.info(
478 f"Processing summary: total_results={len(results)}, processed={processed_count}, "
479 f"errors={error_count}, added={len(news_items)}"
480 )
482 # Log subscription-specific items if we were filtering
483 if subscription_id and subscription_id != "all":
484 sub_items = [
485 item for item in news_items if item.get("is_news", False)
486 ]
487 logger.info(
488 f"Subscription {subscription_id}: found {len(sub_items)} items"
489 )
491 except Exception as db_error:
492 # This block is wide enough to catch far more than database
493 # errors -- everything the feed does with a row runs inside
494 # it. Name the concrete exception type and chain it, so a
495 # plain ValueError from a formatting helper is not reported
496 # to the logs as "Database error" with no way back to the
497 # real raise site. The client still gets the generic detail.
498 logger.exception(
499 "Error in research history query ({}): {}",
500 type(db_error).__name__,
501 db_error,
502 )
503 raise DatabaseAccessException(
504 "research_history_query", _GENERIC_ERROR_DETAIL
505 ) from db_error
507 # If no news items found, return empty list
508 if not news_items:
509 logger.info("No news items found, returning empty list")
510 news_items = []
512 logger.info(f"Returning {len(news_items)} news items to client")
514 # Determine the source
515 source = (
516 "news_items"
517 if any(item.get("is_news", False) for item in news_items)
518 else "research_history"
519 )
521 return {
522 "news_items": news_items[:limit],
523 "generated_at": datetime.now(timezone.utc).isoformat(),
524 "focus": focus,
525 "search_strategy": search_strategy or "default",
526 "total_items": len(news_items),
527 "source": source,
528 }
530 except NewsAPIException:
531 # Re-raise our custom exceptions
532 raise
533 except Exception:
534 logger.exception("Error getting news feed")
535 raise NewsFeedGenerationException(
536 _GENERIC_ERROR_DETAIL, user_id=user_id
537 )
540def get_subscription_history(
541 subscription_id: str,
542 limit: int = 20,
543 username: Optional[str] = None,
544) -> Dict[str, Any]:
545 """
546 Get research history for a specific subscription.
548 Args:
549 subscription_id: The subscription UUID
550 limit: Maximum number of history items to return
551 username: The owner of the subscription (required so we open the
552 correct per-user encrypted database)
554 Returns:
555 Dict containing subscription info and its research history
556 """
557 try:
558 from ..database.session_context import get_user_db_session
559 from ..database.models import ResearchHistory
560 from ..database.models.news import NewsSubscription
562 # Get subscription details using ORM from user's encrypted database
563 with get_user_db_session(username) as session:
564 subscription = (
565 session.query(NewsSubscription)
566 .filter_by(id=subscription_id)
567 .first()
568 )
570 if not subscription:
571 raise SubscriptionNotFoundException(subscription_id) # noqa: TRY301 — re-raised by except NewsAPIException
573 # Convert to dict for response
574 subscription_dict = {
575 "id": subscription.id,
576 "query_or_topic": subscription.query_or_topic,
577 "subscription_type": subscription.subscription_type,
578 "refresh_interval_minutes": subscription.refresh_interval_minutes,
579 # refresh_count is derived from the actual research-run history
580 # below (NewsSubscription has no refresh_count column — reading
581 # it raised AttributeError and 500'd this endpoint).
582 "created_at": subscription.created_at.isoformat()
583 if subscription.created_at
584 else None,
585 "next_refresh": subscription.next_refresh.isoformat()
586 if subscription.next_refresh
587 else None,
588 }
590 # Use the same per-user DB for research history (subscriptions and
591 # ResearchHistory live in the same encrypted database). The
592 # NewsSubscription model has no user_id column — this codebase uses
593 # per-user encrypted databases, so "the subscription's user" is just
594 # whichever user's DB we found the subscription in, identified by the
595 # explicit ``username`` arg (the FastAPI service layer has no Flask
596 # session to fall back on).
597 with get_user_db_session(username) as db_session:
598 # Get all research runs that were triggered by this subscription
599 # Look for subscription_id in the research_meta JSON
600 # Note: JSON format has space after colon
601 like_pattern = (
602 f'%"subscription_id": "{escape_like(subscription_id)}"%'
603 )
604 logger.info(
605 f"Searching for research history with pattern: {like_pattern}"
606 )
608 history_items = (
609 db_session.query(ResearchHistory)
610 .filter(
611 ResearchHistory.research_meta.like(
612 like_pattern, escape="\\"
613 )
614 )
615 .order_by(ResearchHistory.created_at.desc())
616 .limit(limit)
617 .all()
618 )
620 # Convert to dict format for compatibility.
621 # ResearchHistory.id is the UUID PK (see comment on line 151);
622 # there is no separate uuid_id column, so populate both keys
623 # from h.id to preserve the downstream contract used by the
624 # processed_item['research_id']/url builders below.
625 history_items = [
626 {
627 "id": h.id,
628 "uuid_id": h.id,
629 "query": h.query,
630 "status": h.status,
631 # created_at/completed_at are Text columns already holding
632 # isoformat strings (see get_news_feed above), so use them
633 # directly — calling .isoformat() on a str raises
634 # AttributeError and previously broke this endpoint for any
635 # subscription that had research history.
636 "created_at": h.created_at,
637 "completed_at": h.completed_at if h.completed_at else None,
638 "duration_seconds": h.duration_seconds,
639 "research_meta": h.research_meta,
640 "report_path": h.report_path,
641 }
642 for h in history_items
643 ]
645 # Process history items
646 processed_history = []
647 for item in history_items:
648 processed_item = {
649 "research_id": item.get("uuid_id") or str(item.get("id")),
650 "query": item["query"],
651 "status": item["status"],
652 "created_at": item["created_at"],
653 "completed_at": item.get("completed_at"),
654 "duration_seconds": item.get("duration_seconds", 0),
655 "url": f"/progress/{item.get('uuid_id') or item.get('id')}",
656 }
658 # Parse metadata if available to get headline and topics
659 if item.get("research_meta"):
660 try:
661 # research_meta is a JSON column, so SQLAlchemy already
662 # deserializes it to a dict on read; only legacy/text rows
663 # arrive as a JSON string. Calling json.loads() on the dict
664 # raised TypeError that the bare except below swallowed,
665 # silently blanking the headline/topics for every item.
666 # Mirror the dict/str handling already used at the top of
667 # this module (see get_news_feed).
668 raw_meta = item["research_meta"]
669 meta = (
670 json.loads(raw_meta)
671 if isinstance(raw_meta, str)
672 else raw_meta
673 )
674 processed_item["triggered_by"] = meta.get(
675 "triggered_by", "subscription"
676 )
677 # Add headline and topics from metadata
678 processed_item["headline"] = meta.get(
679 "generated_headline", "[No headline]"
680 )
681 processed_item["topics"] = meta.get("generated_topics", [])
682 except Exception:
683 processed_item["headline"] = "[No headline]"
684 processed_item["topics"] = []
685 else:
686 processed_item["headline"] = "[No headline]"
687 processed_item["topics"] = []
689 processed_history.append(processed_item)
691 # Run count comes from the research history (the source of truth);
692 # the subscription row carries no counter.
693 subscription_dict["refresh_count"] = len(processed_history)
695 return {
696 "subscription": subscription_dict,
697 "history": processed_history,
698 "total_runs": len(processed_history),
699 }
701 except NewsAPIException:
702 # Re-raise our custom exceptions
703 raise
704 except Exception:
705 logger.exception("Error getting subscription history")
706 raise DatabaseAccessException(
707 "get_subscription_history", _GENERIC_ERROR_DETAIL
708 )
711def _format_time_ago(timestamp: str) -> str:
712 """Format timestamp as 'X hours ago' string.
714 Raises on unparseable input instead of masking it with a neutral label.
715 ResearchHistory.created_at is a NOT NULL column written as
716 datetime.now(UTC).isoformat() on every insert path, so a value that won't
717 parse means the row is corrupt — not a routine edge case. The only caller
718 (the per-row loop in get_news_feed) already wraps each row in a
719 try/except that logs the failure and skips the row, so a bad timestamp
720 surfaces in the logs and drops that one card rather than rendering it with
721 a misleading "Recently".
722 """
723 from dateutil import parser
725 dt = parser.parse(timestamp)
727 # If dt is naive, assume it's in UTC
728 if dt.tzinfo is None:
729 dt = dt.replace(tzinfo=timezone.utc)
731 now = datetime.now(timezone.utc)
732 diff = now - dt
734 if diff.days > 0:
735 return f"{diff.days} day{'s' if diff.days > 1 else ''} ago"
736 if diff.seconds > 3600:
737 hours = diff.seconds // 3600
738 return f"{hours} hour{'s' if hours > 1 else ''} ago"
739 if diff.seconds > 60:
740 minutes = diff.seconds // 60
741 return f"{minutes} minute{'s' if minutes > 1 else ''} ago"
742 return "Just now"
745def get_subscription(
746 subscription_id: str, username: Optional[str] = None
747) -> Optional[Dict[str, Any]]:
748 """
749 Get a single subscription by ID.
751 Args:
752 subscription_id: Subscription identifier
753 username: User identifier; required to scope the lookup to that
754 user's encrypted database. Without it the lookup falls back
755 to Flask session and will fail under FastAPI.
757 Returns:
758 Dictionary with subscription data or None if not found
759 """
760 try:
761 # Get subscription directly from user's encrypted database
762 from ..database.session_context import get_user_db_session
763 from ..database.models.news import NewsSubscription
765 with get_user_db_session(username) as db_session:
766 subscription = (
767 db_session.query(NewsSubscription)
768 .filter_by(id=subscription_id)
769 .first()
770 )
772 if not subscription:
773 raise SubscriptionNotFoundException(subscription_id) # noqa: TRY301 — re-raised by except NewsAPIException
775 # Convert to API format matching the template expectations
776 return {
777 "id": subscription.id,
778 "name": subscription.name or "",
779 "query_or_topic": subscription.query_or_topic,
780 "subscription_type": subscription.subscription_type,
781 "refresh_interval_minutes": subscription.refresh_interval_minutes,
782 "is_active": subscription.status == "active",
783 "status": subscription.status,
784 "folder_id": subscription.folder_id,
785 "model_provider": subscription.model_provider,
786 "model": subscription.model,
787 "search_strategy": subscription.search_strategy,
788 "custom_endpoint": subscription.custom_endpoint,
789 "search_engine": subscription.search_engine,
790 "search_iterations": subscription.search_iterations or 3,
791 "questions_per_iteration": subscription.questions_per_iteration
792 or 5,
793 "created_at": subscription.created_at.isoformat()
794 if subscription.created_at
795 else None,
796 "updated_at": subscription.updated_at.isoformat()
797 if subscription.updated_at
798 else None,
799 }
801 except NewsAPIException:
802 # Re-raise our custom exceptions
803 raise
804 except Exception:
805 from ..web.routers.notes import _log_value_preview
807 logger.exception(
808 "Error getting subscription {}", _log_value_preview(subscription_id)
809 )
810 raise DatabaseAccessException("get_subscription", _GENERIC_ERROR_DETAIL)
813def get_subscriptions(user_id: str) -> Dict[str, Any]:
814 """
815 Get all subscriptions for a user.
817 Args:
818 user_id: User identifier
820 Returns:
821 Dictionary with subscriptions list
822 """
823 try:
824 # Get subscriptions directly from user's encrypted database
825 from ..database.session_context import get_user_db_session
826 from ..database.models import ResearchHistory
827 from ..database.models.news import NewsSubscription
828 from sqlalchemy import func
830 sub_list: list[dict[str, Any]] = []
832 with get_user_db_session(user_id) as db_session:
833 # Query all subscriptions for this user
834 subscriptions = db_session.query(NewsSubscription).all()
836 for sub in subscriptions:
837 # Count actual research runs for this subscription.
838 # sub.id is an internal PK (not user input), but escape it
839 # too so every subscription_id LIKE in this module is
840 # uniformly wildcard-safe — a no-op for UUID-shaped ids.
841 like_pattern = f'%"subscription_id": "{escape_like(sub.id)}"%'
842 total_runs = (
843 db_session.query(func.count(ResearchHistory.id))
844 .filter(
845 ResearchHistory.research_meta.like(
846 like_pattern, escape="\\"
847 )
848 )
849 .scalar()
850 or 0
851 )
853 # Convert ORM object to API format
854 sub_dict = {
855 "id": sub.id,
856 "query": sub.query_or_topic,
857 "type": sub.subscription_type,
858 "refresh_minutes": sub.refresh_interval_minutes,
859 "created_at": sub.created_at.isoformat()
860 if sub.created_at
861 else None,
862 "next_refresh": sub.next_refresh.isoformat()
863 if sub.next_refresh
864 else None,
865 "last_refreshed": sub.last_refresh.isoformat()
866 if sub.last_refresh
867 else None,
868 "is_active": sub.status == "active",
869 "status": sub.status,
870 "total_runs": total_runs, # Use actual count from research_history
871 "name": sub.name or "",
872 "folder_id": sub.folder_id,
873 # Model/search config: included so consumers (the
874 # subscriptions UI, run-now callers) see the subscription's
875 # saved configuration instead of silently getting None and
876 # falling back to global defaults.
877 "model_provider": sub.model_provider,
878 "model": sub.model,
879 "search_strategy": sub.search_strategy,
880 "search_engine": sub.search_engine,
881 "custom_endpoint": sub.custom_endpoint,
882 "search_iterations": sub.search_iterations,
883 "questions_per_iteration": sub.questions_per_iteration,
884 }
885 sub_list.append(sub_dict)
887 return {"subscriptions": sub_list, "total": len(sub_list)}
889 except Exception:
890 logger.exception("Error getting subscriptions")
891 raise DatabaseAccessException(
892 "get_subscriptions", _GENERIC_ERROR_DETAIL
893 )
896def update_subscription(
897 subscription_id: str,
898 data: Dict[str, Any],
899 username: Optional[str] = None,
900) -> Dict[str, Any]:
901 """
902 Update an existing subscription.
904 Args:
905 subscription_id: Subscription identifier
906 data: Dictionary with fields to update
907 username: User identifier; required to scope the update to that
908 user's encrypted database.
910 Returns:
911 Dictionary with updated subscription data
912 """
913 if "refresh_interval_minutes" in data:
914 validate_refresh_interval(data["refresh_interval_minutes"])
916 try:
917 from ..database.session_context import get_user_db_session
918 from ..database.models.news import NewsSubscription
919 from datetime import datetime, timedelta
921 with get_user_db_session(username) as db_session:
922 # Get existing subscription
923 subscription = (
924 db_session.query(NewsSubscription)
925 .filter_by(id=subscription_id)
926 .first()
927 )
928 if not subscription:
929 raise SubscriptionNotFoundException(subscription_id) # noqa: TRY301 — re-raised by except NewsAPIException
931 # Update fields
932 if "name" in data:
933 subscription.name = data["name"]
934 if "query_or_topic" in data:
935 subscription.query_or_topic = data["query_or_topic"]
936 if "subscription_type" in data: 936 ↛ 937line 936 didn't jump to line 937 because the condition on line 936 was never true
937 subscription.subscription_type = data["subscription_type"]
938 if "refresh_interval_minutes" in data:
939 old_interval = subscription.refresh_interval_minutes
940 subscription.refresh_interval_minutes = data[
941 "refresh_interval_minutes"
942 ]
943 # Recalculate next_refresh if interval changed
944 if old_interval != subscription.refresh_interval_minutes: 944 ↛ 948line 944 didn't jump to line 948 because the condition on line 944 was always true
945 subscription.next_refresh = datetime.now(UTC) + timedelta(
946 minutes=subscription.refresh_interval_minutes
947 )
948 if "is_active" in data:
949 subscription.status = (
950 "active" if data["is_active"] else "paused"
951 )
952 if "status" in data: 952 ↛ 953line 952 didn't jump to line 953 because the condition on line 952 was never true
953 subscription.status = data["status"]
954 if "folder_id" in data:
955 subscription.folder_id = data["folder_id"]
956 if "model_provider" in data:
957 subscription.model_provider = normalize_provider(
958 data["model_provider"]
959 )
960 if "model" in data:
961 subscription.model = data["model"]
962 if "search_strategy" in data: 962 ↛ 963line 962 didn't jump to line 963 because the condition on line 962 was never true
963 subscription.search_strategy = data["search_strategy"]
964 if "custom_endpoint" in data: 964 ↛ 965line 964 didn't jump to line 965 because the condition on line 964 was never true
965 subscription.custom_endpoint = data["custom_endpoint"]
966 if "search_engine" in data:
967 subscription.search_engine = data["search_engine"]
968 if "search_iterations" in data:
969 subscription.search_iterations = data["search_iterations"]
970 if "questions_per_iteration" in data:
971 subscription.questions_per_iteration = data[
972 "questions_per_iteration"
973 ]
975 # N14: if this update touched the engine or provider, validate
976 # the resulting (effective) values against the user's egress
977 # policy before committing. Best-effort — needs a request
978 # context to resolve the current user's settings DB.
979 if "search_engine" in data or "model_provider" in data:
980 # FastAPI: resolve the user from the explicit param (the router
981 # passes username=username), falling back to the request-context
982 # var. The old Flask `has_request_context()` gate was always
983 # False under FastAPI, silently skipping this create/update-time
984 # egress-policy check.
985 _uid = username or get_current_username()
986 if _uid:
987 policy_reason = _validate_subscription_policy(
988 db_session,
989 _uid,
990 subscription.search_engine,
991 subscription.model_provider,
992 )
993 if policy_reason is not None:
994 raise SubscriptionUpdateException( # noqa: TRY301 — re-raised by except NewsAPIException
995 subscription_id, policy_reason
996 )
998 # Update timestamp
999 subscription.updated_at = datetime.now(UTC)
1001 # Commit changes
1002 db_session.commit()
1004 # Notify scheduler about updated subscription
1005 _notify_scheduler_about_subscription_change("updated")
1007 # Convert to API format
1008 return {
1009 "status": "success",
1010 "subscription": {
1011 "id": subscription.id,
1012 "name": subscription.name or "",
1013 "query_or_topic": subscription.query_or_topic,
1014 "subscription_type": subscription.subscription_type,
1015 "refresh_interval_minutes": subscription.refresh_interval_minutes,
1016 "is_active": subscription.status == "active",
1017 "status": subscription.status,
1018 "folder_id": subscription.folder_id,
1019 "model_provider": subscription.model_provider,
1020 "model": subscription.model,
1021 "search_strategy": subscription.search_strategy,
1022 "custom_endpoint": subscription.custom_endpoint,
1023 "search_engine": subscription.search_engine,
1024 "search_iterations": subscription.search_iterations or 3,
1025 "questions_per_iteration": subscription.questions_per_iteration
1026 or 5,
1027 },
1028 }
1030 except NewsAPIException:
1031 # Re-raise our custom exceptions
1032 raise
1033 except Exception:
1034 logger.exception("Error updating subscription")
1035 raise SubscriptionUpdateException(
1036 subscription_id, _GENERIC_ERROR_DETAIL
1037 )
1040def _validate_subscription_policy(
1041 db_session: Session, user_id, search_engine, model_provider
1042) -> Optional[str]:
1043 """Validate a subscription's search engine + LLM provider against the
1044 user's current egress policy (N14).
1046 News subscriptions store a fixed engine/provider that runs on a
1047 schedule. The factory PEP catches a forbidden engine at execution
1048 time, but validating at create/update time stops a forbidden config
1049 from being persisted in the first place (and gives the user
1050 immediate feedback). Returns a human-readable reason string when the
1051 subscription should be rejected, or None when it's allowed.
1053 Best-effort: if the settings backend or policy module is unavailable
1054 (e.g. programmatic API use without a settings DB), validation is
1055 skipped — the execution-time factory PEP remains the backstop. An
1056 invalid configured setting is not such a failure: the check cannot run,
1057 so the subscription is rejected with the setting's diagnostic, which
1058 names the key but not the value.
1059 """
1060 try:
1061 from ..utilities.db_utils import get_settings_manager
1062 from ..security.egress.policy import (
1063 PolicyDeniedError,
1064 context_from_snapshot,
1065 evaluate_engine,
1066 evaluate_llm_endpoint,
1067 resolve_run_primary_engine,
1068 )
1070 settings_manager = get_settings_manager(db_session, user_id)
1071 snapshot = settings_manager.get_settings_snapshot()
1072 if not isinstance(snapshot, dict):
1073 return None
1074 try:
1075 ctx = context_from_snapshot(
1076 snapshot,
1077 resolve_run_primary_engine(snapshot),
1078 username=user_id,
1079 )
1080 except PolicyDeniedError as exc:
1081 return f"egress policy refused: {exc.decision.reason}"
1082 except SettingsValueError:
1083 # A ValueError subclass, but not an incoherent policy: the outer
1084 # handler names the invalid setting.
1085 raise
1086 except ValueError as exc:
1087 # An incoherent egress config makes context_from_snapshot
1088 # raise ValueError. Surface it as a validation failure at
1089 # subscription create/update time instead of letting the outer
1090 # except silently skip the check — otherwise the subscription
1091 # persists and only fails at execution time. Do not echo the raw
1092 # ValueError to the client (CWE-209): it can carry policy-config
1093 # internals; log it server-side instead. Mirrors the same fix in
1094 # web/routers/research.py's start-research precheck.
1095 logger.bind(policy_audit=True).warning(
1096 "Subscription egress policy misconfigured",
1097 reason=str(exc),
1098 )
1099 return "egress policy is misconfigured"
1101 if search_engine:
1102 decision = evaluate_engine(
1103 search_engine, ctx, settings_snapshot=snapshot
1104 )
1105 if not decision.allowed:
1106 return (
1107 f"search engine '{search_engine}' is not permitted "
1108 f"under the current egress policy ({decision.reason})"
1109 )
1110 if model_provider:
1111 decision = evaluate_llm_endpoint(
1112 normalize_provider(model_provider),
1113 ctx,
1114 settings_snapshot=snapshot,
1115 )
1116 if not decision.allowed:
1117 return (
1118 f"LLM provider '{model_provider}' is not permitted "
1119 f"under the current egress policy ({decision.reason})"
1120 )
1121 return None
1122 except SettingsValueError as error:
1123 # The snapshot read validates every effective setting, so one invalid
1124 # row anywhere lands here. Skipping the check would persist a
1125 # subscription the policy was never applied to.
1126 return error.diagnostic
1127 except Exception:
1128 # Settings/policy unavailable → skip; execution-time PEP backstops.
1129 logger.debug("subscription policy pre-check skipped", exc_info=True)
1130 return None
1133def create_subscription(
1134 user_id: str,
1135 query: str,
1136 subscription_type: str = "search",
1137 refresh_minutes: Optional[int] = None,
1138 source_research_id: Optional[str] = None,
1139 model_provider: Optional[str] = None,
1140 model: Optional[str] = None,
1141 search_strategy: Optional[str] = None,
1142 custom_endpoint: Optional[str] = None,
1143 name: Optional[str] = None,
1144 folder_id: Optional[str] = None,
1145 is_active: bool = True,
1146 search_engine: Optional[str] = None,
1147 search_iterations: Optional[int] = None,
1148 questions_per_iteration: Optional[int] = None,
1149) -> Dict[str, Any]:
1150 """
1151 Create a new subscription for user.
1153 Args:
1154 user_id: User identifier
1155 query: Search query or topic
1156 subscription_type: "search" or "topic"
1157 refresh_minutes: Refresh interval in minutes
1159 Returns:
1160 Dictionary with subscription details
1161 """
1162 try:
1163 from ..database.session_context import get_user_db_session
1164 from ..database.models.news import NewsSubscription
1165 from datetime import datetime, timedelta
1166 import uuid
1168 # Get default refresh interval from settings if not provided
1169 # NOTE: This API function accesses the settings DB for convenience when used
1170 # within the Flask application context. For programmatic API access outside
1171 # the web context, callers should provide refresh_minutes explicitly to avoid
1172 # dependency on the settings database being initialized.
1174 with get_user_db_session(user_id) as db_session:
1175 # N14: reject a subscription whose engine/provider violates
1176 # the user's current egress policy before persisting it.
1177 policy_reason = _validate_subscription_policy(
1178 db_session, user_id, search_engine, model_provider
1179 )
1180 if policy_reason is not None:
1181 raise SubscriptionCreationException( # noqa: TRY301 — re-raised as-is by except SubscriptionCreationException
1182 policy_reason,
1183 {"query": query, "type": subscription_type},
1184 )
1186 if refresh_minutes is None:
1187 try:
1188 from ..utilities.db_utils import get_settings_manager
1190 settings_manager = get_settings_manager(db_session, user_id)
1191 refresh_minutes = settings_manager.get_setting(
1192 "news.subscription.refresh_minutes", 240
1193 )
1194 except (ImportError, AttributeError, TypeError):
1195 # Fallback for when settings DB is not available (e.g., programmatic API usage)
1196 logger.debug(
1197 "Settings manager not available, using default refresh_minutes"
1198 )
1199 refresh_minutes = 240 # Default to 4 hours
1200 validate_refresh_interval(refresh_minutes)
1202 # Create new subscription
1203 subscription = NewsSubscription(
1204 id=str(uuid.uuid4()),
1205 name=name,
1206 query_or_topic=query,
1207 subscription_type=subscription_type,
1208 refresh_interval_minutes=refresh_minutes,
1209 status="active" if is_active else "paused",
1210 model_provider=normalize_provider(model_provider),
1211 model=model,
1212 search_strategy=search_strategy or "news_aggregation",
1213 custom_endpoint=custom_endpoint,
1214 folder_id=folder_id,
1215 search_engine=search_engine,
1216 search_iterations=search_iterations,
1217 questions_per_iteration=questions_per_iteration,
1218 created_at=datetime.now(UTC),
1219 updated_at=datetime.now(UTC),
1220 last_refresh=None,
1221 next_refresh=datetime.now(UTC)
1222 + timedelta(minutes=refresh_minutes),
1223 source_id=source_research_id,
1224 )
1226 # Add to database
1227 db_session.add(subscription)
1228 db_session.commit()
1230 # Notify scheduler about new subscription
1231 _notify_scheduler_about_subscription_change("created", user_id)
1233 return {
1234 "status": "success",
1235 "subscription_id": subscription.id,
1236 "type": subscription_type,
1237 "query": query,
1238 "refresh_minutes": refresh_minutes,
1239 }
1241 except NewsAPIException:
1242 # Preserve structured policy and input-validation errors.
1243 raise
1244 except Exception:
1245 logger.exception("Error creating subscription")
1246 raise SubscriptionCreationException(
1247 _GENERIC_ERROR_DETAIL,
1248 {"query": query, "type": subscription_type},
1249 )
1252def delete_subscription(
1253 subscription_id: str, username: Optional[str] = None
1254) -> Dict[str, Any]:
1255 """
1256 Delete a subscription.
1258 Args:
1259 subscription_id: ID of subscription to delete
1260 username: User identifier; required to scope to user's encrypted DB.
1262 Returns:
1263 Dictionary with status
1264 """
1265 try:
1266 from ..database.session_context import get_user_db_session
1267 from ..database.models.news import NewsSubscription
1269 with get_user_db_session(username) as db_session:
1270 subscription = (
1271 db_session.query(NewsSubscription)
1272 .filter_by(id=subscription_id)
1273 .first()
1274 )
1275 if subscription:
1276 db_session.delete(subscription)
1277 db_session.commit()
1279 # Notify scheduler about deleted subscription
1280 _notify_scheduler_about_subscription_change("deleted")
1282 return {"status": "success", "deleted": subscription_id}
1283 raise SubscriptionNotFoundException(subscription_id) # noqa: TRY301 — re-raised by except NewsAPIException
1284 except NewsAPIException:
1285 # Re-raise our custom exceptions
1286 raise
1287 except Exception:
1288 logger.exception("Error deleting subscription")
1289 raise SubscriptionDeletionException(
1290 subscription_id, _GENERIC_ERROR_DETAIL
1291 )
1294def get_votes_for_cards(card_ids: list, user_id: str) -> Dict[str, Any]:
1295 """
1296 Get vote counts and user's votes for multiple news cards.
1298 Args:
1299 card_ids: List of card IDs to get votes for
1300 user_id: User identifier (not used - per-user database)
1302 Returns:
1303 Dictionary with vote information for each card
1304 """
1305 from ..database.models.news import UserRating, RatingType
1306 from ..database.session_context import get_user_db_session
1308 # Prefer the request's authenticated username (FastAPI contextvar),
1309 # fall back to the explicit `user_id` arg for off-request callers
1310 # like tests. The earlier version gated this on Flask's
1311 # `has_request_context()` which is always False under FastAPI
1312 # (Flask not running), so it always fell through to `user_id` —
1313 # and that import will raise ImportError outright once Phase 8
1314 # drops Flask from dependencies.
1315 username = get_current_username() or (user_id if user_id else None)
1316 if not username:
1317 raise ValueError("No username available from context or user_id")
1319 try:
1320 # Get database session
1321 with get_user_db_session(username) as db:
1322 results = {}
1324 for card_id in card_ids:
1325 # Get user's vote for this card
1326 user_vote = (
1327 db.query(UserRating)
1328 .filter_by(
1329 card_id=card_id, rating_type=RatingType.RELEVANCE
1330 )
1331 .first()
1332 )
1334 # Count total votes for this card
1335 upvotes = (
1336 db.query(UserRating)
1337 .filter_by(
1338 card_id=card_id,
1339 rating_type=RatingType.RELEVANCE,
1340 rating_value="up",
1341 )
1342 .count()
1343 )
1345 downvotes = (
1346 db.query(UserRating)
1347 .filter_by(
1348 card_id=card_id,
1349 rating_type=RatingType.RELEVANCE,
1350 rating_value="down",
1351 )
1352 .count()
1353 )
1355 results[card_id] = {
1356 "upvotes": upvotes,
1357 "downvotes": downvotes,
1358 "user_vote": user_vote.rating_value if user_vote else None,
1359 }
1361 return {"success": True, "votes": results}
1363 except Exception:
1364 logger.exception("Error getting votes for cards")
1365 raise
1368def submit_feedback(card_id: str, user_id: str, vote: str) -> Dict[str, Any]:
1369 """
1370 Submit feedback (vote) for a news card.
1372 Args:
1373 card_id: ID of the news card
1374 user_id: User identifier (not used - per-user database)
1375 vote: "up" or "down"
1377 Returns:
1378 Dictionary with updated vote counts
1379 """
1380 from sqlalchemy_utc import utcnow
1381 from ..database.models.news import UserRating, RatingType
1382 from ..database.session_context import get_user_db_session
1384 # Validate vote value
1385 if vote not in ["up", "down"]:
1386 # (#6938: the vote is raw request input and this message reaches
1387 # the router's logged traceback, so it carries the bounded preview.)
1388 from ..web.routers.notes import _log_value_preview
1390 raise ValueError(f"Invalid vote type: {_log_value_preview(vote)}")
1392 # Prefer the request's authenticated username (FastAPI contextvar),
1393 # fall back to the explicit `user_id` arg for off-request callers.
1394 # See the sibling `get_votes_for_cards` for why the Flask
1395 # `has_request_context()` gate is gone.
1396 username = get_current_username() or (user_id if user_id else None)
1397 if not username:
1398 raise ValueError("No username available from context or user_id")
1400 try:
1401 # Get database session
1402 with get_user_db_session(username) as db:
1403 # We don't check if the card exists in the database since news items
1404 # are generated dynamically and may not be stored as NewsCard entries
1406 # Check if user already voted on this card
1407 existing_rating = (
1408 db.query(UserRating)
1409 .filter_by(card_id=card_id, rating_type=RatingType.RELEVANCE)
1410 .first()
1411 )
1413 if existing_rating:
1414 # Update existing vote
1415 existing_rating.rating_value = vote
1416 existing_rating.created_at = utcnow()
1417 else:
1418 # Create new rating
1419 new_rating = UserRating(
1420 card_id=card_id,
1421 rating_type=RatingType.RELEVANCE,
1422 rating_value=vote,
1423 )
1424 db.add(new_rating)
1426 db.commit()
1428 # Count total votes for this card
1429 upvotes = (
1430 db.query(UserRating)
1431 .filter_by(
1432 card_id=card_id,
1433 rating_type=RatingType.RELEVANCE,
1434 rating_value="up",
1435 )
1436 .count()
1437 )
1439 downvotes = (
1440 db.query(UserRating)
1441 .filter_by(
1442 card_id=card_id,
1443 rating_type=RatingType.RELEVANCE,
1444 rating_value="down",
1445 )
1446 .count()
1447 )
1449 # (#6938: card_id is the raw body/path value.)
1450 from ..web.routers.notes import _log_value_preview
1452 logger.info(
1453 "Feedback submitted for card {}: {} (up: {}, down: {})",
1454 _log_value_preview(card_id),
1455 vote,
1456 upvotes,
1457 downvotes,
1458 )
1460 return {
1461 "success": True,
1462 "card_id": card_id,
1463 "vote": vote,
1464 "upvotes": upvotes,
1465 "downvotes": downvotes,
1466 }
1468 except Exception:
1469 from ..web.routers.notes import _log_value_preview
1471 logger.exception(
1472 "Error submitting feedback for card {}",
1473 _log_value_preview(card_id),
1474 )
1475 raise
1478def research_news_item(card_id: str, depth: str = "quick") -> Dict[str, Any]:
1479 """
1480 Perform deeper research on a news item.
1482 Args:
1483 card_id: ID of the news card to research
1484 depth: Research depth - "quick", "detailed", or "report"
1486 Returns:
1487 Dictionary with research results
1488 """
1489 # TODO: Implement with per-user database for cards
1490 logger.warning(
1491 "research_news_item not yet implemented with per-user databases"
1492 )
1493 raise NotImplementedException("research_news_item")
1496def save_news_preferences(
1497 user_id: str, preferences: Dict[str, Any]
1498) -> Dict[str, Any]:
1499 """
1500 Save user preferences for news.
1502 Args:
1503 user_id: User identifier
1504 preferences: Dictionary of preferences to save
1506 Returns:
1507 Dictionary with status and message
1508 """
1509 # TODO: Implement with per-user database for preferences
1510 logger.warning(
1511 "save_news_preferences not yet implemented with per-user databases"
1512 )
1513 raise NotImplementedException("save_news_preferences")
1516def get_news_categories() -> Dict[str, Any]:
1517 """
1518 Get available news categories with counts.
1520 Returns:
1521 Dictionary with categories and statistics
1522 """
1523 # TODO: Implement with per-user database for categories
1524 logger.warning(
1525 "get_news_categories not yet implemented with per-user databases"
1526 )
1527 raise NotImplementedException("get_news_categories")