Coverage for src / local_deep_research / web / routes / news_routes.py: 93%
133 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-25 01:07 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-25 01:07 +0000
1"""
2Flask routes for news API endpoints.
3"""
5from functools import wraps
7from flask import Blueprint, jsonify, request, session
8from loguru import logger
10from ...news import api as news_api
11from ...news.exceptions import NewsAPIException
12from ..auth.decorators import login_required
13from ..utils.rate_limiter import limiter
15# Create blueprint
16bp = Blueprint("news_api", __name__, url_prefix="/api/news")
18# Shared rate limits for POST endpoints
19_news_create_limit = limiter.shared_limit("10 per minute", scope="news_create")
20_news_research_limit = limiter.shared_limit(
21 "5 per minute", scope="news_research"
22)
23_news_feedback_limit = limiter.shared_limit(
24 "30 per minute", scope="news_feedback"
25)
26_news_preferences_limit = limiter.shared_limit(
27 "10 per minute", scope="news_preferences"
28)
31def handle_api_errors(f):
32 """Decorator to handle API errors consistently across news endpoints."""
34 @wraps(f)
35 def wrapper(*args, **kwargs):
36 try:
37 return f(*args, **kwargs)
38 except NewsAPIException:
39 raise
40 except Exception:
41 logger.exception("Unexpected error in %s", f.__name__)
42 return jsonify({"error": "Internal server error"}), 500
44 return wrapper
47@bp.errorhandler(NewsAPIException)
48def handle_news_api_exception(error: NewsAPIException):
49 """Handle NewsAPIException and convert to JSON response."""
50 logger.error(f"News API error: {error.message} (code: {error.error_code})")
51 return jsonify(error.to_dict()), error.status_code
54@bp.route("/feed", methods=["GET"])
55@login_required
56@handle_api_errors
57def get_news_feed():
58 """Get personalized news feed."""
59 user_id = session.get("username")
60 limit = request.args.get("limit", 20, type=int)
61 limit = max(1, min(limit, 200))
62 use_cache = request.args.get("use_cache", "true").lower() == "true"
63 focus = request.args.get("focus")
64 search_strategy = request.args.get("search_strategy")
65 subscription_id = request.args.get("subscription_id")
67 result = news_api.get_news_feed(
68 user_id=user_id,
69 limit=limit,
70 use_cache=use_cache,
71 focus=focus,
72 search_strategy=search_strategy,
73 subscription_id=subscription_id,
74 )
76 return jsonify(result)
79@bp.route("/subscriptions", methods=["GET"])
80@login_required
81@handle_api_errors
82def get_subscriptions():
83 """Get all subscriptions for the current user."""
84 user_id = session.get("username")
85 result = news_api.get_subscriptions(user_id)
86 return jsonify(result)
89@bp.route("/subscriptions", methods=["POST"])
90@login_required
91@handle_api_errors
92@_news_create_limit
93def create_subscription():
94 """Create a new subscription."""
95 user_id = session.get("username")
96 data = request.get_json()
97 if data is None: 97 ↛ 98line 97 didn't jump to line 98 because the condition on line 97 was never true
98 return jsonify({"error": "Request body must be valid JSON"}), 400
100 result = news_api.create_subscription(
101 user_id=user_id,
102 query=data.get("query"),
103 subscription_type=data.get("type", "search"),
104 refresh_minutes=data.get("refresh_minutes"),
105 source_research_id=data.get("source_research_id"),
106 model_provider=data.get("model_provider"),
107 model=data.get("model"),
108 search_strategy=data.get("search_strategy"),
109 custom_endpoint=data.get("custom_endpoint"),
110 name=data.get("name"),
111 folder_id=data.get("folder_id"),
112 is_active=data.get("is_active", True),
113 search_engine=data.get("search_engine"),
114 search_iterations=data.get("search_iterations"),
115 questions_per_iteration=data.get("questions_per_iteration"),
116 )
118 return jsonify(result), 201
121@bp.route("/subscriptions/<subscription_id>", methods=["GET"])
122@login_required
123@handle_api_errors
124def get_subscription(subscription_id):
125 """Get a single subscription by ID."""
126 result = news_api.get_subscription(subscription_id)
127 return jsonify(result)
130@bp.route("/subscriptions/<subscription_id>", methods=["PUT", "PATCH"])
131@login_required
132@handle_api_errors
133def update_subscription(subscription_id):
134 """Update an existing subscription."""
135 data = request.get_json()
136 if data is None: 136 ↛ 137line 136 didn't jump to line 137 because the condition on line 136 was never true
137 return jsonify({"error": "Request body must be valid JSON"}), 400
138 result = news_api.update_subscription(subscription_id, data)
139 return jsonify(result)
142@bp.route("/subscriptions/<subscription_id>", methods=["DELETE"])
143@login_required
144@handle_api_errors
145def delete_subscription(subscription_id):
146 """Delete a subscription."""
147 result = news_api.delete_subscription(subscription_id)
148 return jsonify(result)
151@bp.route("/subscriptions/<subscription_id>/history", methods=["GET"])
152@login_required
153@handle_api_errors
154def get_subscription_history(subscription_id):
155 """Get research history for a specific subscription."""
156 limit = request.args.get("limit", 20, type=int)
157 limit = max(1, min(limit, 200))
158 result = news_api.get_subscription_history(subscription_id, limit)
159 return jsonify(result)
162@bp.route("/feedback", methods=["POST"])
163@login_required
164@handle_api_errors
165@_news_feedback_limit
166def submit_feedback():
167 """Submit feedback (vote) for a news card."""
168 user_id = session.get("username")
169 data = request.get_json()
170 if data is None: 170 ↛ 171line 170 didn't jump to line 171 because the condition on line 170 was never true
171 return jsonify({"error": "Request body must be valid JSON"}), 400
173 card_id = data.get("card_id")
174 vote = data.get("vote")
176 if not card_id or vote not in ["up", "down"]:
177 return jsonify({"error": "Invalid request"}), 400
179 result = news_api.submit_feedback(card_id, user_id, vote)
180 return jsonify(result)
183@bp.route("/research", methods=["POST"])
184@login_required
185@handle_api_errors
186@_news_research_limit
187def research_news_item():
188 """Perform deeper research on a news item."""
189 data = request.get_json()
190 if data is None: 190 ↛ 191line 190 didn't jump to line 191 because the condition on line 190 was never true
191 return jsonify({"error": "Request body must be valid JSON"}), 400
192 card_id = data.get("card_id")
193 depth = data.get("depth", "quick")
195 if not card_id:
196 return jsonify({"error": "card_id is required"}), 400
198 result = news_api.research_news_item(card_id, depth)
199 return jsonify(result)
202@bp.route("/preferences", methods=["POST"])
203@login_required
204@handle_api_errors
205@_news_preferences_limit
206def save_preferences():
207 """Save user preferences for news."""
208 user_id = session.get("username")
209 preferences = request.get_json()
210 if preferences is None: 210 ↛ 211line 210 didn't jump to line 211 because the condition on line 210 was never true
211 return jsonify({"error": "Request body must be valid JSON"}), 400
213 result = news_api.save_news_preferences(user_id, preferences)
214 return jsonify(result)
217@bp.route("/categories", methods=["GET"])
218@login_required
219@handle_api_errors
220def get_categories():
221 """Get available news categories with counts."""
222 result = news_api.get_news_categories()
223 return jsonify(result)