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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""Shared constants for the news subsystem."""
3from ..database.models.news import SubscriptionFolder
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
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)
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 )
35def is_valid_refresh_interval(value) -> bool:
36 """Whether ``value`` may be written as a refresh interval now.
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)
45def is_runnable_refresh_interval(value) -> bool:
46 """Whether an already-saved interval is safe to schedule and run.
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 )
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
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 )
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
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 )
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
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"})
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)
136def _unsupported_update_fields_message(unsupported_fields):
137 """Reflect at most eight sorted names, each at most 32 characters.
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
152def compact_subscription_update_error(data):
153 """Return the validation message for a compact subscription update, or None.
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}"
168 if "is_active" in data and type(data["is_active"]) is not bool:
169 return "is_active must be a boolean"
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'"
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 )
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"
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 )
207 return None
210def normalize_compact_subscription_update(data):
211 error = compact_subscription_update_error(data)
212 if error is not None:
213 raise ValueError(error)
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
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)
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)
251def folder_update_error(data):
252 """Return the validation message for a folder update, or None.
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}"
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"
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"
278 if "is_default" in data and type(data["is_default"]) is not bool:
279 return "is_default must be a boolean"
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"
289 return None
292def normalize_folder_update(data):
293 """Validate a folder update payload against the allowlisted fields.
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.
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.
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)
326 return {
327 field: data[field] for field in FOLDER_UPDATABLE_FIELDS if field in data
328 }