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

1""" 

2Flask routes for news API endpoints. 

3""" 

4 

5import uuid 

6from functools import wraps 

7 

8from flask import Blueprint, jsonify, request, session 

9from loguru import logger 

10 

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 

16 

17 

18def _is_valid_uuid(value: str) -> bool: 

19 """Return True if ``value`` parses as a UUID, False otherwise. 

20 

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 

32 

33 

34# Create blueprint 

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

36 

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. 

40 

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) 

52 

53 

54def handle_api_errors(f): 

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

56 

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 

66 

67 return wrapper 

68 

69 

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 

77 

78 

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

91 

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 

99 

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 ) 

108 

109 return jsonify(result) 

110 

111 

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) 

120 

121 

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 ) 

148 

149 return jsonify(result), 201 

150 

151 

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) 

159 

160 

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) 

170 

171 

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) 

179 

180 

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) 

197 

198 

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

210 

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

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

213 

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

215 return jsonify(result) 

216 

217 

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

228 

229 if not card_id: 

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

231 

232 result = news_api.research_news_item(card_id, depth) 

233 return jsonify(result) 

234 

235 

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) 

247 

248 

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)