Coverage for src/local_deep_research/news/constants.py: 100%

80 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1"""Shared constants for the news subsystem.""" 

2 

3from ..database.models.news import SubscriptionFolder 

4 

5# Subscription scheduling bounds. Shared by the create route and both 

6# update routes (``web/routers/news_flask_api.py`` imports these back) so 

7# their limits cannot drift apart. 

8NEWS_SUBSCRIPTION_MIN_REFRESH_MINUTES = 1 

9# Cap on a value being written now: create, and both update routes. 

10NEWS_SUBSCRIPTION_MAX_REFRESH_MINUTES = 10_080 

11# Ceiling on a value that is already saved. v1.10.7 stored whatever interval 

12# it was sent -- its form set only min="1" and its docs allowed up to 30 days 

13# -- so rows above the write cap exist and must keep running after an 

14# upgrade. One year is far past that documented range and keeps every 

15# ``timedelta``/``datetime``/APScheduler date computed from the value 

16# comfortably in range; anything beyond it is not a usable schedule. 

17NEWS_SUBSCRIPTION_MAX_SAVED_REFRESH_MINUTES = 525_600 

18 

19# Shown when a saved row cannot be run until the user fixes its interval. 

20# Shared by the manual run, the overdue sweep and the research-start guard. 

21SUBSCRIPTION_INTERVAL_REPAIR_MESSAGE = ( 

22 "Edit this subscription's refresh interval before running it" 

23) 

24 

25 

26def _is_whole_minutes_up_to(value, maximum: int) -> bool: 

27 # ``type(...) is int`` rather than ``isinstance`` so bool, float (NaN and 

28 # infinity included), str and None are all rejected. 

29 return ( 

30 type(value) is int 

31 and NEWS_SUBSCRIPTION_MIN_REFRESH_MINUTES <= value <= maximum 

32 ) 

33 

34 

35def is_valid_refresh_interval(value) -> bool: 

36 """Whether ``value`` may be written as a refresh interval now. 

37 

38 Applies to a new subscription and to an interval sent with an edit. A 

39 row saved before the cap existed is judged by 

40 :func:`is_runnable_refresh_interval` instead. 

41 """ 

42 return _is_whole_minutes_up_to(value, NEWS_SUBSCRIPTION_MAX_REFRESH_MINUTES) 

43 

44 

45def is_runnable_refresh_interval(value) -> bool: 

46 """Whether an already-saved interval is safe to schedule and run. 

47 

48 Accepts every whole number of minutes the released form could store, up 

49 to :data:`NEWS_SUBSCRIPTION_MAX_SAVED_REFRESH_MINUTES`. Rejects what 

50 cannot be scheduled: non-integers (NaN, fractions, text, null), zero and 

51 negatives (APScheduler turns a zero interval into a one-second trigger) 

52 and anything beyond the ceiling. 

53 """ 

54 return _is_whole_minutes_up_to( 

55 value, NEWS_SUBSCRIPTION_MAX_SAVED_REFRESH_MINUTES 

56 ) 

57 

58 

59def validate_refresh_interval(value) -> None: 

60 """Reject an interval that may not be written (create or edit).""" 

61 if not is_valid_refresh_interval(value): 

62 from .exceptions import NewsAPIException 

63 

64 raise NewsAPIException( 

65 "Refresh interval must be an integer between " 

66 f"{NEWS_SUBSCRIPTION_MIN_REFRESH_MINUTES} and " 

67 f"{NEWS_SUBSCRIPTION_MAX_REFRESH_MINUTES} minutes", 

68 status_code=400, 

69 error_code="INVALID_REFRESH_INTERVAL", 

70 ) 

71 

72 

73def validate_runnable_refresh_interval(value) -> None: 

74 """Reject a saved interval before a run's schedule is advanced from it.""" 

75 if not is_runnable_refresh_interval(value): 

76 from .exceptions import NewsAPIException 

77 

78 raise NewsAPIException( 

79 "Saved refresh interval must be an integer between " 

80 f"{NEWS_SUBSCRIPTION_MIN_REFRESH_MINUTES} and " 

81 f"{NEWS_SUBSCRIPTION_MAX_SAVED_REFRESH_MINUTES} minutes", 

82 status_code=400, 

83 error_code="INVALID_REFRESH_INTERVAL", 

84 ) 

85 

86 

87# ``NewsSubscription.name`` is ``Column(String(255))`` (database/models/ 

88# news.py:63) -- this mirrors that column's length, it is not an 

89# independently chosen limit. 

90COMPACT_SUBSCRIPTION_NAME_MAX_LENGTH = 255 

91 

92COMPACT_SUBSCRIPTION_DIRECT_FIELDS = ( 

93 "folder", 

94 "folder_id", 

95 "name", 

96 "notes", 

97 "refresh_interval_minutes", 

98) 

99COMPACT_SUBSCRIPTION_INPUT_FIELDS = frozenset( 

100 (*COMPACT_SUBSCRIPTION_DIRECT_FIELDS, "is_active", "status") 

101) 

102COMPACT_SUBSCRIPTION_STATUSES = frozenset({"active", "paused"}) 

103 

104# The exact key set of the response body 

105# ``news_flask_api._update_subscription_folder_sync`` returns from a 

106# successful PUT /news/api/subscription/subscriptions/{id}. NewsSubscription 

107# has no ``to_dict()`` (unlike SubscriptionFolder below), so this is hand-kept 

108# in sync with that dict literal rather than introspected off the model -- 

109# deliberately NOT "every NewsSubscription column minus the allowlist", which 

110# would also swallow genuinely sensitive, non-response columns such as 

111# ``custom_endpoint`` (the very field #5603/this PR's docstring call out as 

112# needing to stay rejected, not silently dropped). 

113COMPACT_SUBSCRIPTION_RESPONSE_FIELDS = frozenset( 

114 ( 

115 "id", 

116 "name", 

117 "status", 

118 "is_active", 

119 "folder_id", 

120 "refresh_interval_minutes", 

121 "next_refresh", 

122 "last_refresh", 

123 ) 

124) 

125# Fields the endpoint's own response echoes back that are not writable. 

126# A client that PUTs its own prior PUT response back (change one field, 

127# resend the rest) always resends these; rejecting them broke that pattern. 

128# They are still never written -- see the allowlist-only comprehension at 

129# the end of normalize_compact_subscription_update -- only no longer 

130# rejected for merely being present. 

131COMPACT_SUBSCRIPTION_READONLY_ECHO_FIELDS = ( 

132 COMPACT_SUBSCRIPTION_RESPONSE_FIELDS - COMPACT_SUBSCRIPTION_INPUT_FIELDS 

133) 

134 

135 

136def _unsupported_update_fields_message(unsupported_fields): 

137 """Reflect at most eight sorted names, each at most 32 characters. 

138 

139 Including separators and the omitted-count suffix, this stays below 

140 320 characters. This bounds reflected content, not parsing or sorting. 

141 """ 

142 fields = sorted(unsupported_fields) 

143 message = ", ".join( 

144 field if len(field) <= 32 else field[:29] + "..." 

145 for field in fields[:8] 

146 ) 

147 if len(fields) > 8: 

148 message += f", ... (+{len(fields) - 8} more)" 

149 return message 

150 

151 

152def compact_subscription_update_error(data): 

153 """Return the validation message for a compact subscription update, or None. 

154 

155 Routes return this string to the client directly rather than echoing 

156 ``str()`` of a caught exception. Unsupported names are reflected with 

157 bounded content; other validation messages are fixed. 

158 """ 

159 unsupported_fields = ( 

160 set(data) 

161 - COMPACT_SUBSCRIPTION_INPUT_FIELDS 

162 - COMPACT_SUBSCRIPTION_READONLY_ECHO_FIELDS 

163 ) 

164 if unsupported_fields: 

165 fields = _unsupported_update_fields_message(unsupported_fields) 

166 return f"Unsupported subscription update fields: {fields}" 

167 

168 if "is_active" in data and type(data["is_active"]) is not bool: 

169 return "is_active must be a boolean" 

170 

171 if "status" in data and ( 

172 type(data["status"]) is not str 

173 or data["status"] not in COMPACT_SUBSCRIPTION_STATUSES 

174 ): 

175 return "status must be 'active' or 'paused'" 

176 

177 if "refresh_interval_minutes" in data and not is_valid_refresh_interval( 

178 data["refresh_interval_minutes"] 

179 ): 

180 return ( 

181 "refresh_interval_minutes must be an integer between " 

182 f"{NEWS_SUBSCRIPTION_MIN_REFRESH_MINUTES} and " 

183 f"{NEWS_SUBSCRIPTION_MAX_REFRESH_MINUTES}" 

184 ) 

185 

186 for field in ("folder", "folder_id", "name", "notes"): 

187 if ( 

188 field in data 

189 and data[field] is not None 

190 and type(data[field]) is not str 

191 ): 

192 return f"{field} must be a string or null" 

193 

194 # ``name`` is user-editable through the full ``PUT /subscriptions/{id}`` 

195 # route's ``field_mapping`` (web/routers/news_flask_api.py) and shares 

196 # that column's length bound here. 

197 if ( 

198 "name" in data 

199 and isinstance(data["name"], str) 

200 and len(data["name"]) > COMPACT_SUBSCRIPTION_NAME_MAX_LENGTH 

201 ): 

202 return ( 

203 "name exceeds maximum length of " 

204 f"{COMPACT_SUBSCRIPTION_NAME_MAX_LENGTH} characters" 

205 ) 

206 

207 return None 

208 

209 

210def normalize_compact_subscription_update(data): 

211 error = compact_subscription_update_error(data) 

212 if error is not None: 

213 raise ValueError(error) 

214 

215 update_data = { 

216 field: data[field] 

217 for field in COMPACT_SUBSCRIPTION_DIRECT_FIELDS 

218 if field in data 

219 } 

220 if "is_active" in data: 

221 update_data["status"] = "active" if data["is_active"] else "paused" 

222 if "status" in data: 

223 update_data["status"] = data["status"] 

224 return update_data 

225 

226 

227# The SubscriptionFolder model's user-editable columns (see 

228# database/models/news.py). id/created_at/updated_at are deliberately 

229# excluded -- they are server-managed, so setattr must never reach them -- 

230# but see FOLDER_READONLY_ECHO_FIELDS below: excluded from *writes* is not 

231# the same as rejecting a request that merely *mentions* them. 

232FOLDER_UPDATABLE_FIELDS = frozenset( 

233 ("name", "description", "color", "icon", "is_default", "sort_order") 

234) 

235 

236# SubscriptionFolder.to_dict() emits every column on the model, including 

237# id/created_at/updated_at, which are not in FOLDER_UPDATABLE_FIELDS above. 

238# A read-modify-write client (GET the folder, change ``name``, PUT the whole 

239# to_dict() body back) always resends those three. Derived from the live 

240# model's columns -- not hardcoded -- so it can't drift from what to_dict() 

241# actually returns; anything that's a real column here is inherently safe to 

242# ignore rather than reject, since FOLDER_UPDATABLE_FIELDS below still 

243# decides what actually gets written regardless of what this set allows 

244# through the door. 

245FOLDER_READONLY_ECHO_FIELDS = ( 

246 frozenset(column.name for column in SubscriptionFolder.__table__.columns) 

247 - FOLDER_UPDATABLE_FIELDS 

248) 

249 

250 

251def folder_update_error(data): 

252 """Return the validation message for a folder update, or None. 

253 

254 See :func:`normalize_folder_update` for the rules. Like 

255 :func:`compact_subscription_update_error`, this lets routes return a 

256 fixed message without echoing ``str()`` of a caught exception. 

257 """ 

258 unsupported_fields = ( 

259 set(data) - FOLDER_UPDATABLE_FIELDS - FOLDER_READONLY_ECHO_FIELDS 

260 ) 

261 if unsupported_fields: 

262 fields = _unsupported_update_fields_message(unsupported_fields) 

263 return f"Unsupported folder update fields: {fields}" 

264 

265 # ``name`` is NOT NULL on the SubscriptionFolder model (database/models/ 

266 # news.py), so unlike description/color/icon it cannot accept a null. 

267 if "name" in data and type(data["name"]) is not str: 

268 return "name must be a string" 

269 

270 for field in ("description", "color", "icon"): 

271 if ( 

272 field in data 

273 and data[field] is not None 

274 and type(data[field]) is not str 

275 ): 

276 return f"{field} must be a string or null" 

277 

278 if "is_default" in data and type(data["is_default"]) is not bool: 

279 return "is_default must be a boolean" 

280 

281 # ``type(x) is not int`` (rather than ``isinstance``) also rejects a 

282 # bool masquerading as an int -- ``type(True) is bool``, not ``int`` -- 

283 # matching the convention already used above for ``is_default`` and by 

284 # ``normalize_compact_subscription_update`` for ``is_active``/ 

285 # ``refresh_interval_minutes``. 

286 if "sort_order" in data and type(data["sort_order"]) is not int: 

287 return "sort_order must be an integer" 

288 

289 return None 

290 

291 

292def normalize_folder_update(data): 

293 """Validate a folder update payload against the allowlisted fields. 

294 

295 ``FolderManager.update_folder`` used to accept any key for which 

296 ``hasattr(folder, key)`` was true on the live SQLAlchemy instance -- 

297 which holds not just for real columns but also for ORM internals like 

298 ``_sa_instance_state``, ``metadata``/``registry``, and the bound 

299 ``to_dict`` method. A request body could therefore corrupt ORM 

300 identity state or shadow ``to_dict`` on the instance (breaking the 

301 route's own ``folder.to_dict()`` call on the same request). Restrict 

302 updates to the model's real, user-editable columns instead. 

303 

304 Field-name allowlisting alone isn't enough: ``FolderManager.update_folder`` 

305 does a blind ``setattr(folder, key, value)`` loop over whatever survives 

306 here, straight into the SQLAlchemy session. A wrong-typed value (e.g. 

307 ``{"sort_order": "abc"}``) either dies as an opaque 500 at 

308 ``session.commit()`` or, thanks to SQLite's dynamic column typing, 

309 silently coerces/stores the wrong type instead of erroring at all. So 

310 type-check each field here too, mirroring 

311 :func:`normalize_compact_subscription_update`'s style -- an invalid body 

312 must never reach the session. 

313 

314 Keys in :data:`FOLDER_READONLY_ECHO_FIELDS` (``id``, ``created_at``, 

315 ``updated_at``) are accepted but ignored rather than rejected: they are 

316 exactly what ``folder.to_dict()`` echoes back, so a client doing a 

317 normal read-modify-write round trip always resends them. The final 

318 dict comprehension below still only pulls from 

319 ``FOLDER_UPDATABLE_FIELDS``, so none of them ever reaches ``setattr`` -- 

320 this only changes whether *mentioning* them 400s the whole request. 

321 """ 

322 error = folder_update_error(data) 

323 if error is not None: 

324 raise ValueError(error) 

325 

326 return { 

327 field: data[field] for field in FOLDER_UPDATABLE_FIELDS if field in data 

328 }