Coverage for src / local_deep_research / web / routes / news_routes.py: 100%
129 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-14 23:55 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-14 23:55 +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 ...security.decorators import require_json_body
13from ..auth.decorators import login_required
14from ...security.rate_limiter import limiter
16# Create blueprint
17bp = Blueprint("news_api", __name__, url_prefix="/api/news")
19# NOTE: Routes use session["username"] (not .get()) intentionally.
20# @login_required guarantees the key exists; direct access fails fast
21# if the decorator is ever removed.
23# Shared rate limits for POST endpoints
24_news_create_limit = limiter.shared_limit("10 per minute", scope="news_create")
25_news_research_limit = limiter.shared_limit(
26 "5 per minute", scope="news_research"
27)
28_news_feedback_limit = limiter.shared_limit(
29 "30 per minute", scope="news_feedback"
30)
31_news_preferences_limit = limiter.shared_limit(
32 "10 per minute", scope="news_preferences"
33)
36def handle_api_errors(f):
37 """Decorator to handle API errors consistently across news endpoints."""
39 @wraps(f)
40 def wrapper(*args, **kwargs):
41 try:
42 return f(*args, **kwargs)
43 except NewsAPIException:
44 raise
45 except Exception:
46 logger.exception("Unexpected error in {}", f.__name__)
47 return jsonify({"error": "Internal server error"}), 500
49 return wrapper
52@bp.errorhandler(NewsAPIException)
53def handle_news_api_exception(error: NewsAPIException):
54 """Handle NewsAPIException and convert to JSON response."""
55 logger.error(
56 "News API error: {} (status {})", error.error_code, error.status_code
57 )
58 return jsonify(error.to_dict()), error.status_code
61@bp.route("/feed", methods=["GET"])
62@login_required
63@handle_api_errors
64def get_news_feed():
65 """Get personalized news feed."""
66 user_id = session["username"]
67 limit = request.args.get("limit", 20, type=int)
68 limit = max(1, min(limit, 200))
69 use_cache = request.args.get("use_cache", "true").lower() == "true"
70 focus = request.args.get("focus")
71 search_strategy = request.args.get("search_strategy")
72 subscription_id = request.args.get("subscription_id")
74 result = news_api.get_news_feed(
75 user_id=user_id,
76 limit=limit,
77 use_cache=use_cache,
78 focus=focus,
79 search_strategy=search_strategy,
80 subscription_id=subscription_id,
81 )
83 return jsonify(result)
86@bp.route("/subscriptions", methods=["GET"])
87@login_required
88@handle_api_errors
89def get_subscriptions():
90 """Get all subscriptions for the current user."""
91 user_id = session["username"]
92 result = news_api.get_subscriptions(user_id)
93 return jsonify(result)
96@bp.route("/subscriptions", methods=["POST"])
97@login_required
98@handle_api_errors
99@_news_create_limit
100@require_json_body()
101def create_subscription():
102 """Create a new subscription."""
103 user_id = session["username"]
104 data = request.get_json()
105 result = news_api.create_subscription(
106 user_id=user_id,
107 query=data.get("query"),
108 subscription_type=data.get("type", "search"),
109 refresh_minutes=data.get("refresh_minutes"),
110 source_research_id=data.get("source_research_id"),
111 model_provider=data.get("model_provider"),
112 model=data.get("model"),
113 search_strategy=data.get("search_strategy"),
114 custom_endpoint=data.get("custom_endpoint"),
115 name=data.get("name"),
116 folder_id=data.get("folder_id"),
117 is_active=data.get("is_active", True),
118 search_engine=data.get("search_engine"),
119 search_iterations=data.get("search_iterations"),
120 questions_per_iteration=data.get("questions_per_iteration"),
121 )
123 return jsonify(result), 201
126@bp.route("/subscriptions/<subscription_id>", methods=["GET"])
127@login_required
128@handle_api_errors
129def get_subscription(subscription_id):
130 """Get a single subscription by ID."""
131 result = news_api.get_subscription(subscription_id)
132 return jsonify(result)
135@bp.route("/subscriptions/<subscription_id>", methods=["PUT", "PATCH"])
136@login_required
137@handle_api_errors
138@require_json_body()
139def update_subscription(subscription_id):
140 """Update an existing subscription."""
141 data = request.get_json()
142 result = news_api.update_subscription(subscription_id, data)
143 return jsonify(result)
146@bp.route("/subscriptions/<subscription_id>", methods=["DELETE"])
147@login_required
148@handle_api_errors
149def delete_subscription(subscription_id):
150 """Delete a subscription."""
151 result = news_api.delete_subscription(subscription_id)
152 return jsonify(result)
155@bp.route("/subscriptions/<subscription_id>/history", methods=["GET"])
156@login_required
157@handle_api_errors
158def get_subscription_history(subscription_id):
159 """Get research history for a specific subscription."""
160 limit = request.args.get("limit", 20, type=int)
161 limit = max(1, min(limit, 200))
162 result = news_api.get_subscription_history(subscription_id, limit)
163 return jsonify(result)
166@bp.route("/feedback", methods=["POST"])
167@login_required
168@handle_api_errors
169@_news_feedback_limit
170@require_json_body()
171def submit_feedback():
172 """Submit feedback (vote) for a news card."""
173 user_id = session["username"]
174 data = request.get_json()
175 card_id = data.get("card_id")
176 vote = data.get("vote")
178 if not card_id or vote not in ["up", "down"]:
179 return jsonify({"error": "Invalid request"}), 400
181 result = news_api.submit_feedback(card_id, user_id, vote)
182 return jsonify(result)
185@bp.route("/research", methods=["POST"])
186@login_required
187@handle_api_errors
188@_news_research_limit
189@require_json_body()
190def research_news_item():
191 """Perform deeper research on a news item."""
192 data = request.get_json()
193 card_id = data.get("card_id")
194 depth = data.get("depth", "quick")
196 if not card_id:
197 return jsonify({"error": "card_id is required"}), 400
199 result = news_api.research_news_item(card_id, depth)
200 return jsonify(result)
203@bp.route("/preferences", methods=["POST"])
204@login_required
205@handle_api_errors
206@_news_preferences_limit
207@require_json_body()
208def save_preferences():
209 """Save user preferences for news."""
210 user_id = session["username"]
211 preferences = request.get_json()
212 result = news_api.save_news_preferences(user_id, preferences)
213 return jsonify(result)
216@bp.route("/categories", methods=["GET"])
217@login_required
218@handle_api_errors
219def get_categories():
220 """Get available news categories with counts."""
221 result = news_api.get_news_categories()
222 return jsonify(result)