Coverage for dakweb/queries/changelog.py: 0%
91 statements
« prev ^ index » next coverage.py v6.5.0, created at 2025-09-18 21:26 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2025-09-18 21:26 +0000
1"""Packages' changelogs related queries
3@contact: Debian FTPMaster <ftpmaster@debian.org>
4@copyright: 2025 Sérgio Cipriano <cipriano@debian.org>
5@license: GNU General Public License version 2 or later
6"""
8import json
9from datetime import datetime
11import bottle
12from sqlalchemy import TIMESTAMP, cast, or_
14from daklib.dbconn import DBChange, DBChangelog, DBConn
15from dakweb.webregister import QueryRegister
18@bottle.route("/changelogs")
19def changelogs() -> str:
20 """Legacy endpoint; prefer /v2/changelogs.
22 Returns all matching rows (no pagination). Use only for backward compatibility.
23 Required param: search_term.
24 """
26 search_term = bottle.request.query.get("search_term", "").strip()
28 bottle.response.content_type = "application/json; charset=UTF-8"
29 # Add advisory / deprecation style headers
30 bottle.response.set_header(
31 "Warning",
32 '299 dakweb "/changelogs is legacy; prefer /v2/changelogs (adds pagination & filters)"',
33 )
34 bottle.response.set_header("X-Legacy-Endpoint", "true")
35 bottle.response.set_header("X-Preferred-Endpoint", "/v2/changelogs")
37 if not search_term:
38 bottle.response.status = 400
39 return json.dumps({"error": "Missing required query parameter: search_term"})
41 s = DBConn().session()
43 q = (
44 s.query(
45 DBChange.date,
46 DBChange.source,
47 DBChange.version,
48 DBChange.changedby,
49 DBChangelog.changelog,
50 )
51 .join(DBChangelog, DBChange.changelog_id == DBChangelog.id)
52 .filter(DBChange.source != "debian-keyring")
53 .filter(
54 or_(
55 DBChangelog.changelog.ilike(f"%{search_term}%"),
56 DBChange.changedby.ilike(f"%{search_term}%"),
57 )
58 )
59 .order_by(DBChange.seen)
60 )
62 ret = []
63 for c in q:
64 ret.append(
65 {
66 "date": c.date,
67 "source": c.source,
68 "version": c.version,
69 "changedby": c.changedby,
70 "changelog": c.changelog,
71 }
72 )
74 s.close()
76 return json.dumps(ret)
79QueryRegister().register_path("/changelogs", changelogs)
82@bottle.route("/v2/changelogs")
83def changelogs_v2() -> str:
84 """Improved changelogs endpoint with date range & pagination.
86 Query parameters:
87 - search_term: (optional) substring to match in changelog text or changedby (ILIKE)
88 - since: (optional) ISO date/datetime (e.g. 2024-01-01 or 2024-01-01T12:00:00)
89 - till: (optional) ISO date/datetime upper bound (inclusive)
90 - source: (optional) exact source package name to restrict
91 - since_id: (optional) return only rows with id > since_id (id-based polling)
92 - limit: (optional) default 50, max 500
93 - offset: (optional) default 0
95 Returns JSON object with keys: count, limit, offset, has_more, results[].
96 """
98 bottle.response.content_type = "application/json; charset=UTF-8"
100 search_term = bottle.request.query.get("search_term", "").strip()
101 source_filter = bottle.request.query.get("source", "").strip()
102 since_param = bottle.request.query.get("since", "").strip()
103 till_param = bottle.request.query.get("till", "").strip()
104 since_id_str = bottle.request.query.get("since_id", "").strip()
106 # Pagination params
107 try:
108 limit = int(bottle.request.query.get("limit", "50"))
109 except ValueError:
110 limit = 50
111 limit = max(1, min(limit, 500))
112 try:
113 offset = int(bottle.request.query.get("offset", "0"))
114 except ValueError:
115 offset = 0
116 offset = max(0, offset)
118 def parse_dt(val: str):
119 if not val:
120 return None
121 # Accept date-only or full isoformat
122 try:
123 if len(val) == 10: # YYYY-MM-DD
124 return datetime.strptime(val, "%Y-%m-%d")
125 return datetime.fromisoformat(val)
126 except Exception:
127 return None
129 since_dt = parse_dt(since_param)
130 till_dt = parse_dt(till_param)
132 if since_param and not since_dt:
133 bottle.response.status = 400
134 return json.dumps({"error": "Invalid since parameter (expected ISO date)"})
135 if till_param and not till_dt:
136 bottle.response.status = 400
137 return json.dumps({"error": "Invalid till parameter (expected ISO date)"})
139 # Validate id-based filter
140 since_id = None
141 if since_id_str:
142 try:
143 since_id = int(since_id_str)
144 except Exception:
145 bottle.response.status = 400
146 return json.dumps(
147 {"error": "Invalid since_id parameter (expected integer)"}
148 )
149 if since_id < 0:
150 bottle.response.status = 400
151 return json.dumps({"error": "since_id must be >= 0"})
153 s = DBConn().session()
154 try:
155 q = (
156 s.query(
157 DBChange.change_id,
158 DBChange.date,
159 DBChange.source,
160 DBChange.version,
161 DBChange.changedby,
162 DBChangelog.changelog,
163 )
164 .join(DBChangelog, DBChange.changelog_id == DBChangelog.id)
165 .filter(DBChange.source != "debian-keyring")
166 )
168 if search_term:
169 like = f"%{search_term}%"
170 q = q.filter(
171 or_(
172 DBChangelog.changelog.ilike(like),
173 DBChange.changedby.ilike(like),
174 )
175 )
176 if source_filter:
177 q = q.filter(DBChange.source == source_filter)
178 # Date column may be stored as TEXT; cast to timestamp for comparison
179 if since_dt:
180 q = q.filter(cast(DBChange.date, TIMESTAMP) >= since_dt)
181 if till_dt:
182 # Inclusive upper bound
183 q = q.filter(cast(DBChange.date, TIMESTAMP) <= till_dt)
184 if since_id is not None:
185 # Strictly greater than to avoid returning the last seen row again
186 q = q.filter(DBChange.change_id > since_id)
188 q = q.order_by(DBChange.seen, DBChange.change_id) # deterministic ordering
190 rows = q.offset(offset).limit(limit + 1).all() # fetch one extra for has_more
191 has_more = len(rows) > limit
192 rows = rows[:limit]
194 results = [
195 {
196 "id": r.change_id,
197 "date": (
198 r.date.isoformat() if hasattr(r.date, "isoformat") else str(r.date)
199 ),
200 "source": r.source,
201 "version": r.version,
202 "changedby": r.changedby,
203 "changelog": r.changelog,
204 }
205 for r in rows
206 ]
208 return json.dumps(
209 {
210 "count": len(results),
211 "limit": limit,
212 "offset": offset,
213 "has_more": has_more,
214 "results": results,
215 }
216 )
217 finally:
218 s.close()
221QueryRegister().register_path("/v2/changelogs", changelogs_v2)