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

1""" 

2Flask routes for news API endpoints. 

3""" 

4 

5from functools import wraps 

6 

7from flask import Blueprint, jsonify, request, session 

8from loguru import logger 

9 

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 

15 

16# Create blueprint 

17bp = Blueprint("news_api", __name__, url_prefix="/api/news") 

18 

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. 

22 

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) 

34 

35 

36def handle_api_errors(f): 

37 """Decorator to handle API errors consistently across news endpoints.""" 

38 

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 

48 

49 return wrapper 

50 

51 

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 

59 

60 

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") 

73 

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 ) 

82 

83 return jsonify(result) 

84 

85 

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) 

94 

95 

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 ) 

122 

123 return jsonify(result), 201 

124 

125 

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) 

133 

134 

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) 

144 

145 

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) 

153 

154 

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) 

164 

165 

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") 

177 

178 if not card_id or vote not in ["up", "down"]: 

179 return jsonify({"error": "Invalid request"}), 400 

180 

181 result = news_api.submit_feedback(card_id, user_id, vote) 

182 return jsonify(result) 

183 

184 

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") 

195 

196 if not card_id: 

197 return jsonify({"error": "card_id is required"}), 400 

198 

199 result = news_api.research_news_item(card_id, depth) 

200 return jsonify(result) 

201 

202 

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) 

214 

215 

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)