Coverage for dakweb/routers/changelog.py: 94%
55 statements
« prev ^ index » next coverage.py v7.6.0, created at 2026-08-03 16:46 +0000
« prev ^ index » next coverage.py v7.6.0, created at 2026-08-03 16:46 +0000
1# SPDX-License-Identifier: GPL-2.0-or-later
2# © 2025, Sérgio Cipriano <cipriano@debian.org>
3# © 2026, Ansgar 🙀 <ansgar@debian.org>
4# © 2026, Anton Gladky <gladk@debian.org>
6from datetime import datetime
7from typing import Annotated
9from fastapi import APIRouter, Depends, Query, Response
10from pydantic import AfterValidator, BaseModel
11from sqlalchemy import TIMESTAMP, cast, or_, select
12from sqlalchemy.orm import Session
14from daklib.dbconn import DBChange, DBChangelog
15from dakweb.routers.deps import get_db
17router = APIRouter()
20class ChangelogV1(BaseModel):
21 date: str
22 source: str
23 version: str
24 changedby: str
25 changelog: str
28@router.get("/changelogs")
29def changelogs(
30 response: Response,
31 search_term: Annotated[
32 str,
33 AfterValidator(str.strip),
34 Query(
35 description="Substring to match in changelog text or changedby",
36 min_length=1,
37 ),
38 ],
39 db: Session = Depends(get_db),
40) -> list[ChangelogV1]:
41 """Legacy endpoint; prefer /v2/changelogs.
43 Returns all matching rows (no pagination). Use only for backward compatibility.
44 """
46 # Add advisory / deprecation style headers
47 response.headers["Warning"] = (
48 '299 dakweb "/changelogs is legacy; prefer /v2/changelogs (adds pagination & filters)"'
49 )
50 response.headers["X-Legacy-Endpoint"] = "true"
51 response.headers["X-Preferred-Endpoint"] = "/v2/changelogs"
53 stmt = (
54 select(
55 DBChange.date,
56 DBChange.source,
57 DBChange.version,
58 DBChange.changedby,
59 DBChangelog.changelog,
60 )
61 .join(DBChangelog, DBChange.changelog_id == DBChangelog.id)
62 .where(DBChange.source != "debian-keyring")
63 .where(
64 or_(
65 DBChangelog.changelog.ilike(f"%{search_term}%"),
66 DBChange.changedby.ilike(f"%{search_term}%"),
67 )
68 )
69 .order_by(DBChange.seen)
70 )
72 return [
73 ChangelogV1(
74 date=c.date,
75 source=c.source,
76 version=c.version,
77 changedby=c.changedby,
78 changelog=c.changelog,
79 )
80 for c in db.execute(stmt)
81 ]
84class Changelog(BaseModel):
85 id: int
86 date: str
87 source: str
88 version: str
89 changedby: str
90 changelog: str
93class ChangelogResponse(BaseModel):
94 count: int
95 limit: int
96 offset: int
97 has_more: bool
98 results: list[Changelog]
101@router.get("/v2/changelogs")
102def changelogs_v2(
103 search_term: Annotated[
104 Annotated[str, AfterValidator(str.strip)] | None,
105 Query(description="Substring to match in changelog text or changedby"),
106 ] = None,
107 source: Annotated[
108 Annotated[str, AfterValidator(str.strip)] | None,
109 Query(description="Exact source package name to restrict"),
110 ] = None,
111 since: Annotated[datetime | None, Query(description="ISO date/datetime")] = None,
112 till: Annotated[
113 datetime | None, Query(description="ISO date/datetime upper bound (inclusive)")
114 ] = None,
115 since_id: Annotated[
116 int | None, Query(description="Return only rows with id > since_id", ge=0)
117 ] = None,
118 limit: Annotated[int, Query(ge=1, le=500)] = 50,
119 offset: Annotated[int, Query(ge=0)] = 0,
120 db: Session = Depends(get_db),
121) -> ChangelogResponse:
122 """Improved changelogs endpoint with date range & pagination."""
124 stmt = (
125 select(
126 DBChange.change_id,
127 DBChange.date,
128 DBChange.source,
129 DBChange.version,
130 DBChange.changedby,
131 DBChangelog.changelog,
132 )
133 .join(DBChangelog, DBChange.changelog_id == DBChangelog.id)
134 .where(DBChange.source != "debian-keyring")
135 )
137 if search_term:
138 like = f"%{search_term}%"
139 stmt = stmt.where(
140 or_(
141 DBChangelog.changelog.ilike(like),
142 DBChange.changedby.ilike(like),
143 )
144 )
146 if source:
147 stmt = stmt.where(DBChange.source == source)
149 # Date column may be stored as TEXT; cast to timestamp for comparison
150 if since: 150 ↛ 151line 150 didn't jump to line 151 because the condition on line 150 was never true
151 stmt = stmt.where(cast(DBChange.date, TIMESTAMP) >= since)
152 if till: 152 ↛ 154line 152 didn't jump to line 154 because the condition on line 152 was never true
153 # Inclusive upper bound
154 stmt = stmt.where(cast(DBChange.date, TIMESTAMP) <= till)
155 if since_id is not None:
156 # Strictly greater than to avoid returning the last seen row again
157 stmt = stmt.where(DBChange.change_id > since_id)
159 # Order by change_id so since_id acts as a stable cursor for pagination.
160 stmt = stmt.order_by(DBChange.change_id)
162 rows = db.execute(stmt.offset(offset).limit(limit + 1)).all()
163 has_more = len(rows) > limit
164 rows = rows[:limit]
166 results = [
167 Changelog(
168 id=r.change_id,
169 date=r.date,
170 source=r.source,
171 version=r.version,
172 changedby=r.changedby,
173 changelog=r.changelog,
174 )
175 for r in rows
176 ]
177 return ChangelogResponse(
178 count=len(results),
179 limit=limit,
180 offset=offset,
181 has_more=has_more,
182 results=results,
183 )