Coverage for src/local_deep_research/web/routes/news_routes.py: 100%
140 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-03 23:15 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-03 23:15 +0000
1"""
2Flask routes for news API endpoints.
3"""
5import uuid
6from functools import wraps
8from flask import Blueprint, jsonify, request, session
9from loguru import logger
11from ...news import api as news_api
12from ...news.exceptions import NewsAPIException
13from ...security.decorators import require_json_body
14from ..auth.decorators import login_required
15from ...security.rate_limiter import limiter
18def _is_valid_uuid(value: str) -> bool:
19 """Return True if ``value`` parses as a UUID, False otherwise.
21 Used to validate path/query subscription_id parameters before they
22 reach the LIKE-pattern queries in ``news/api.py``. Without this
23 check, a request like ``?subscription_id=%`` would expand the
24 LIKE filter and match arbitrary subscriptions (enumeration vector,
25 though not data exfiltration since user-DB isolation still applies).
26 """
27 try:
28 uuid.UUID(str(value))
29 except (ValueError, AttributeError, TypeError):
30 return False
31 return True
34# Create blueprint
35bp = Blueprint("news_api", __name__, url_prefix="/api/news")
37# NOTE: Routes use session["username"] (not .get()) intentionally.
38# @login_required guarantees the key exists; direct access fails fast
39# if the decorator is ever removed.
41# Shared rate limits for POST endpoints
42_news_create_limit = limiter.shared_limit("10 per minute", scope="news_create")
43_news_research_limit = limiter.shared_limit(
44 "5 per minute", scope="news_research"
45)
46_news_feedback_limit = limiter.shared_limit(
47 "30 per minute", scope="news_feedback"
48)
49_news_preferences_limit = limiter.shared_limit(
50 "10 per minute", scope="news_preferences"
51)
54def handle_api_errors(f):
55 """Decorator to handle API errors consistently across news endpoints."""
57 @wraps(f)
58 def wrapper(*args, **kwargs):
59 try:
60 return f(*args, **kwargs)
61 except NewsAPIException:
62 raise
63 except Exception:
64 logger.exception("Unexpected error in {}", f.__name__)
65 return jsonify({"error": "Internal server error"}), 500
67 return wrapper
70@bp.errorhandler(NewsAPIException)
71def handle_news_api_exception(error: NewsAPIException):
72 """Handle NewsAPIException and convert to JSON response."""
73 logger.error(
74 "News API error: {} (status {})", error.error_code, error.status_code
75 )
76 return jsonify(error.to_dict()), error.status_code
79@bp.route("/feed", methods=["GET"])
80@login_required
81@handle_api_errors
82def get_news_feed():
83 """Get personalized news feed."""
84 user_id = session["username"]
85 limit = request.args.get("limit", 20, type=int)
86 limit = max(1, min(limit, 200))
87 use_cache = request.args.get("use_cache", "true").lower() == "true"
88 focus = request.args.get("focus")
89 search_strategy = request.args.get("search_strategy")
90 subscription_id = request.args.get("subscription_id")
92 if subscription_id and not _is_valid_uuid(subscription_id):
93 return jsonify(
94 {
95 "success": False,
96 "error": "Invalid subscription_id",
97 }
98 ), 400
100 result = news_api.get_news_feed(
101 user_id=user_id,
102 limit=limit,
103 use_cache=use_cache,
104 focus=focus,
105 search_strategy=search_strategy,
106 subscription_id=subscription_id,
107 )
109 return jsonify(result)
112@bp.route("/subscriptions", methods=["GET"])
113@login_required
114@handle_api_errors
115def get_subscriptions():
116 """Get all subscriptions for the current user."""
117 user_id = session["username"]
118 result = news_api.get_subscriptions(user_id)
119 return jsonify(result)
122@bp.route("/subscriptions", methods=["POST"])
123@login_required
124@handle_api_errors
125@_news_create_limit
126@require_json_body()
127def create_subscription():
128 """Create a new subscription."""
129 user_id = session["username"]
130 data = request.get_json()
131 result = news_api.create_subscription(
132 user_id=user_id,
133 query=data.get("query"),
134 subscription_type=data.get("type", "search"),
135 refresh_minutes=data.get("refresh_minutes"),
136 source_research_id=data.get("source_research_id"),
137 model_provider=data.get("model_provider"),
138 model=data.get("model"),
139 search_strategy=data.get("search_strategy"),
140 custom_endpoint=data.get("custom_endpoint"),
141 name=data.get("name"),
142 folder_id=data.get("folder_id"),
143 is_active=data.get("is_active", True),
144 search_engine=data.get("search_engine"),
145 search_iterations=data.get("search_iterations"),
146 questions_per_iteration=data.get("questions_per_iteration"),
147 )
149 return jsonify(result), 201
152@bp.route("/subscriptions/<subscription_id>", methods=["GET"])
153@login_required
154@handle_api_errors
155def get_subscription(subscription_id):
156 """Get a single subscription by ID."""
157 result = news_api.get_subscription(subscription_id)
158 return jsonify(result)
161@bp.route("/subscriptions/<subscription_id>", methods=["PUT", "PATCH"])
162@login_required
163@handle_api_errors
164@require_json_body()
165def update_subscription(subscription_id):
166 """Update an existing subscription."""
167 data = request.get_json()
168 result = news_api.update_subscription(subscription_id, data)
169 return jsonify(result)
172@bp.route("/subscriptions/<subscription_id>", methods=["DELETE"])
173@login_required
174@handle_api_errors
175def delete_subscription(subscription_id):
176 """Delete a subscription."""
177 result = news_api.delete_subscription(subscription_id)
178 return jsonify(result)
181@bp.route("/subscriptions/<subscription_id>/history", methods=["GET"])
182@login_required
183@handle_api_errors
184def get_subscription_history(subscription_id):
185 """Get research history for a specific subscription."""
186 if not _is_valid_uuid(subscription_id):
187 return jsonify(
188 {
189 "success": False,
190 "error": "Invalid subscription_id",
191 }
192 ), 400
193 limit = request.args.get("limit", 20, type=int)
194 limit = max(1, min(limit, 200))
195 result = news_api.get_subscription_history(subscription_id, limit)
196 return jsonify(result)
199@bp.route("/feedback", methods=["POST"])
200@login_required
201@handle_api_errors
202@_news_feedback_limit
203@require_json_body()
204def submit_feedback():
205 """Submit feedback (vote) for a news card."""
206 user_id = session["username"]
207 data = request.get_json()
208 card_id = data.get("card_id")
209 vote = data.get("vote")
211 if not card_id or vote not in ["up", "down"]:
212 return jsonify({"error": "Invalid request"}), 400
214 result = news_api.submit_feedback(card_id, user_id, vote)
215 return jsonify(result)
218@bp.route("/research", methods=["POST"])
219@login_required
220@handle_api_errors
221@_news_research_limit
222@require_json_body()
223def research_news_item():
224 """Perform deeper research on a news item."""
225 data = request.get_json()
226 card_id = data.get("card_id")
227 depth = data.get("depth", "quick")
229 if not card_id:
230 return jsonify({"error": "card_id is required"}), 400
232 result = news_api.research_news_item(card_id, depth)
233 return jsonify(result)
236@bp.route("/preferences", methods=["POST"])
237@login_required
238@handle_api_errors
239@_news_preferences_limit
240@require_json_body()
241def save_preferences():
242 """Save user preferences for news."""
243 user_id = session["username"]
244 preferences = request.get_json()
245 result = news_api.save_news_preferences(user_id, preferences)
246 return jsonify(result)
249@bp.route("/categories", methods=["GET"])
250@login_required
251@handle_api_errors
252def get_categories():
253 """Get available news categories with counts."""
254 result = news_api.get_news_categories()
255 return jsonify(result)