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

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 ..auth.decorators import login_required 

13from ..utils.rate_limiter import limiter 

14 

15# Create blueprint 

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

17 

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) 

29 

30 

31def handle_api_errors(f): 

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

33 

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 

43 

44 return wrapper 

45 

46 

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 

52 

53 

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

66 

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 ) 

75 

76 return jsonify(result) 

77 

78 

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) 

87 

88 

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 

99 

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 ) 

117 

118 return jsonify(result), 201 

119 

120 

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) 

128 

129 

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) 

140 

141 

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) 

149 

150 

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) 

160 

161 

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 

172 

173 card_id = data.get("card_id") 

174 vote = data.get("vote") 

175 

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

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

178 

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

180 return jsonify(result) 

181 

182 

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

194 

195 if not card_id: 

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

197 

198 result = news_api.research_news_item(card_id, depth) 

199 return jsonify(result) 

200 

201 

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 

212 

213 result = news_api.save_news_preferences(user_id, preferences) 

214 return jsonify(result) 

215 

216 

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)