Skip to content

Commit 43fc182

Browse files
committed
Add hashtag and editor analytics APIs
1 parent c6a7d68 commit 43fc182

8 files changed

Lines changed: 776 additions & 11 deletions

File tree

api/pg_schema.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
geom GEOMETRY(POLYGON)
1414
);
1515
CREATE INDEX IF NOT EXISTS idx_changesets_created_at ON changesets(created_at);
16+
CREATE INDEX IF NOT EXISTS idx_changesets_hashtags ON changesets USING GIN (hashtags);
17+
CREATE INDEX IF NOT EXISTS idx_changesets_editor ON changesets(editor);
1618
CREATE INDEX IF NOT EXISTS idx_changesets_geom ON changesets USING GIST (geom);
1719
CREATE TABLE IF NOT EXISTS changeset_stats (
1820
changeset_id BIGINT NOT NULL REFERENCES changesets(changeset_id),
@@ -33,6 +35,7 @@
3335
PRIMARY KEY (seq_id, changeset_id)
3436
);
3537
CREATE INDEX IF NOT EXISTS idx_changeset_stats_uid ON changeset_stats(uid);
38+
CREATE INDEX IF NOT EXISTS idx_changeset_stats_changeset_id ON changeset_stats(changeset_id);
3639
CREATE TABLE IF NOT EXISTS state (
3740
source_url TEXT PRIMARY KEY,
3841
last_seq BIGINT NOT NULL,

api/queries.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@
33

44
from .db import get_pool
55

6+
7+
def _map_changes_expr(alias: str = "st") -> str:
8+
return f"""
9+
{alias}.nodes_created + {alias}.nodes_modified + {alias}.nodes_deleted +
10+
{alias}.ways_created + {alias}.ways_modified + {alias}.ways_deleted +
11+
{alias}.rels_created + {alias}.rels_modified + {alias}.rels_deleted
12+
"""
13+
614
_TAG_CTES = """,
715
tag_agg AS (
816
SELECT
@@ -136,6 +144,119 @@ def _user_stats_sql(*, filter_dates: bool, filter_hashtags: bool, include_tags:
136144
"""
137145

138146

147+
def _changeset_filters_sql(*, filter_dates: bool, filter_hashtags: bool = False) -> tuple[str, int]:
148+
n = 1
149+
filters: list[str] = []
150+
if filter_dates:
151+
filters.append(f"cs.created_at >= ${n}")
152+
n += 1
153+
filters.append(f"cs.created_at < ${n}")
154+
n += 1
155+
if filter_hashtags:
156+
filters.append(f"cs.hashtags && ${n}::TEXT[]")
157+
n += 1
158+
where_sql = f"WHERE {' AND '.join(filters)}" if filters else ""
159+
return where_sql, n
160+
161+
162+
def _hashtag_stats_sql(*, filter_dates: bool, filter_hashtags: bool) -> str:
163+
where_sql, n = _changeset_filters_sql(filter_dates=filter_dates, filter_hashtags=filter_hashtags)
164+
limit_param = f"${n}"
165+
offset_param = f"${n + 1}"
166+
map_changes = _map_changes_expr()
167+
return f"""
168+
WITH hashtag_scope AS (
169+
SELECT
170+
ht.hashtag,
171+
st.uid,
172+
st.changeset_id,
173+
({map_changes}) AS map_changes
174+
FROM changesets cs
175+
JOIN changeset_stats st ON st.changeset_id = cs.changeset_id
176+
CROSS JOIN LATERAL UNNEST(cs.hashtags) AS ht(hashtag)
177+
{where_sql}
178+
),
179+
hashtag_totals AS (
180+
SELECT
181+
hashtag,
182+
COUNT(DISTINCT changeset_id) AS changesets,
183+
COUNT(DISTINCT uid) AS users,
184+
COALESCE(SUM(map_changes), 0) AS map_changes
185+
FROM hashtag_scope
186+
GROUP BY hashtag
187+
)
188+
SELECT
189+
hashtag,
190+
changesets,
191+
users,
192+
map_changes,
193+
ROW_NUMBER() OVER (ORDER BY map_changes DESC, hashtag ASC) AS rank
194+
FROM hashtag_totals
195+
ORDER BY map_changes DESC, hashtag ASC
196+
LIMIT {limit_param} OFFSET {offset_param}
197+
"""
198+
199+
200+
def _hashtag_trends_sql(*, filter_hashtags: bool) -> str:
201+
where_sql, n = _changeset_filters_sql(filter_dates=True, filter_hashtags=filter_hashtags)
202+
interval_param = f"${n}"
203+
limit_param = f"${n + 1}"
204+
offset_param = f"${n + 2}"
205+
map_changes = _map_changes_expr()
206+
return f"""
207+
SELECT
208+
DATE_TRUNC({interval_param}, cs.created_at) AS period_start,
209+
ht.hashtag,
210+
COUNT(DISTINCT st.changeset_id) AS changesets,
211+
COUNT(DISTINCT st.uid) AS users,
212+
COALESCE(SUM({map_changes}), 0) AS map_changes
213+
FROM changesets cs
214+
JOIN changeset_stats st ON st.changeset_id = cs.changeset_id
215+
CROSS JOIN LATERAL UNNEST(cs.hashtags) AS ht(hashtag)
216+
{where_sql}
217+
GROUP BY period_start, ht.hashtag
218+
ORDER BY period_start ASC, map_changes DESC, ht.hashtag ASC
219+
LIMIT {limit_param} OFFSET {offset_param}
220+
"""
221+
222+
223+
def _editor_stats_sql(*, filter_dates: bool) -> str:
224+
where_sql, n = _changeset_filters_sql(filter_dates=filter_dates)
225+
limit_param = f"${n}"
226+
offset_param = f"${n + 1}"
227+
map_changes = _map_changes_expr()
228+
return f"""
229+
WITH editor_scope AS (
230+
SELECT
231+
COALESCE(NULLIF(cs.editor, ''), 'unknown') AS editor,
232+
st.uid,
233+
st.changeset_id,
234+
({map_changes}) AS map_changes
235+
FROM changesets cs
236+
JOIN changeset_stats st ON st.changeset_id = cs.changeset_id
237+
{where_sql}
238+
),
239+
editor_totals AS (
240+
SELECT
241+
editor,
242+
COUNT(DISTINCT changeset_id) AS changesets,
243+
COUNT(DISTINCT uid) AS users,
244+
COALESCE(SUM(map_changes), 0) AS map_changes
245+
FROM editor_scope
246+
GROUP BY editor
247+
)
248+
SELECT
249+
editor,
250+
changesets,
251+
users,
252+
map_changes,
253+
ROW_NUMBER() OVER (ORDER BY map_changes DESC, editor ASC) AS rank
254+
FROM editor_totals
255+
ORDER BY map_changes DESC, editor ASC
256+
LIMIT {limit_param} OFFSET {offset_param}
257+
"""
258+
259+
139260
async def fetch_state() -> dict[str, Any] | None:
140261
# last_ts/last_seq come from the worst-lagging source (slowest source bounds real freshness);
141262
# updated_at is the most recent heartbeat across all sources (any tick proves the worker is alive).
@@ -175,3 +296,66 @@ async def fetch_user_stats(
175296
async with get_pool().acquire() as conn:
176297
rows = await conn.fetch(sql, *params)
177298
return [dict(row) for row in rows]
299+
300+
301+
async def fetch_hashtag_stats(
302+
*,
303+
start: datetime | None = None,
304+
end: datetime | None = None,
305+
hashtag: list[str] | None = None,
306+
limit: int = 100,
307+
offset: int = 0,
308+
) -> list[dict[str, Any]]:
309+
filter_dates = start is not None and end is not None
310+
filter_hashtags = bool(hashtag)
311+
sql = _hashtag_stats_sql(filter_dates=filter_dates, filter_hashtags=filter_hashtags)
312+
params: list[Any] = []
313+
if filter_dates:
314+
params.extend([start, end])
315+
if filter_hashtags:
316+
params.append(hashtag)
317+
params.extend([limit, offset])
318+
319+
async with get_pool().acquire() as conn:
320+
rows = await conn.fetch(sql, *params)
321+
return [dict(row) for row in rows]
322+
323+
324+
async def fetch_hashtag_trends(
325+
*,
326+
start: datetime,
327+
end: datetime,
328+
interval: str,
329+
hashtag: list[str] | None = None,
330+
limit: int = 1000,
331+
offset: int = 0,
332+
) -> list[dict[str, Any]]:
333+
filter_hashtags = bool(hashtag)
334+
sql = _hashtag_trends_sql(filter_hashtags=filter_hashtags)
335+
params: list[Any] = [start, end]
336+
if filter_hashtags:
337+
params.append(hashtag)
338+
params.extend([interval, limit, offset])
339+
340+
async with get_pool().acquire() as conn:
341+
rows = await conn.fetch(sql, *params)
342+
return [dict(row) for row in rows]
343+
344+
345+
async def fetch_editor_stats(
346+
*,
347+
start: datetime | None = None,
348+
end: datetime | None = None,
349+
limit: int = 100,
350+
offset: int = 0,
351+
) -> list[dict[str, Any]]:
352+
filter_dates = start is not None and end is not None
353+
sql = _editor_stats_sql(filter_dates=filter_dates)
354+
params: list[Any] = []
355+
if filter_dates:
356+
params.extend([start, end])
357+
params.extend([limit, offset])
358+
359+
async with get_pool().acquire() as conn:
360+
rows = await conn.fetch(sql, *params)
361+
return [dict(row) for row in rows]

api/routers/v1.py

Lines changed: 124 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
1-
from datetime import UTC, datetime
1+
from datetime import UTC, datetime, timedelta
22
from typing import Annotated
33

44
from litestar import Controller, Router, get
55
from litestar.exceptions import HTTPException
66
from litestar.params import Parameter
77

8-
from ..queries import fetch_user_stats
9-
from ..schemas import UserStat, UserStatsResponse
8+
from ..queries import fetch_editor_stats, fetch_hashtag_stats, fetch_hashtag_trends, fetch_user_stats
9+
from ..schemas import (
10+
EditorStat,
11+
EditorStatsResponse,
12+
HashtagStat,
13+
HashtagStatsResponse,
14+
HashtagTrend,
15+
UserStat,
16+
UserStatsResponse,
17+
)
18+
19+
TREND_INTERVALS = {"day", "week", "month"}
1020

1121

1222
def normalize_hashtags(hashtag: list[str] | None) -> list[str] | None:
@@ -27,6 +37,22 @@ def normalize_hashtags(hashtag: list[str] | None) -> list[str] | None:
2737
return normalized or None
2838

2939

40+
def resolve_optional_window(start: datetime | None, end: datetime | None) -> tuple[datetime | None, datetime | None]:
41+
start = start or (datetime.min.replace(tzinfo=UTC) if end else None)
42+
end = end or (datetime.now(tz=UTC) if start else None)
43+
if start and end and start >= end:
44+
raise HTTPException(status_code=400, detail="start must be before end")
45+
return start, end
46+
47+
48+
def resolve_required_window(start: datetime | None, end: datetime | None) -> tuple[datetime, datetime]:
49+
end = end or datetime.now(tz=UTC)
50+
start = start or (end - timedelta(days=30))
51+
if start >= end:
52+
raise HTTPException(status_code=400, detail="start must be before end")
53+
return start, end
54+
55+
3056
class StatsController(Controller):
3157
path = "/stats"
3258

@@ -47,11 +73,7 @@ async def get_user_stats(
4773
limit: Annotated[int, Parameter(ge=1, le=1000, description="Page size (1–1000).")] = 100,
4874
offset: Annotated[int, Parameter(ge=0, description="Page offset.")] = 0,
4975
) -> UserStatsResponse:
50-
start = start or (datetime.min.replace(tzinfo=UTC) if end else None)
51-
end = end or (datetime.now(tz=UTC) if start else None)
52-
if start and end and start >= end:
53-
raise HTTPException(status_code=400, detail="start must be before end")
54-
76+
start, end = resolve_optional_window(start, end)
5577
normalized_hashtag = normalize_hashtags(hashtag)
5678
rows = await fetch_user_stats(
5779
start=start,
@@ -74,4 +96,97 @@ async def get_user_stats(
7496
)
7597

7698

77-
v1_router = Router(path="/api/v1", route_handlers=[StatsController])
99+
class HashtagStatsController(Controller):
100+
path = "/hashtag-stats"
101+
102+
@get()
103+
async def get_hashtag_stats(
104+
self,
105+
start: Annotated[
106+
datetime | None,
107+
Parameter(description="Inclusive UTC lower bound (ISO 8601). Defaults to 30 days before end."),
108+
] = None,
109+
end: Annotated[
110+
datetime | None,
111+
Parameter(description="Exclusive UTC upper bound (ISO 8601). Defaults to now."),
112+
] = None,
113+
hashtag: Annotated[
114+
list[str] | None, Parameter(description="Optional hashtags to limit the leaderboard to. Repeatable.")
115+
] = None,
116+
interval: Annotated[str, Parameter(description="Trend bucket: day, week, or month.")] = "day",
117+
limit: Annotated[int, Parameter(ge=1, le=1000, description="Page size (1-1000).")] = 100,
118+
offset: Annotated[int, Parameter(ge=0, description="Page offset.")] = 0,
119+
) -> HashtagStatsResponse:
120+
if interval not in TREND_INTERVALS:
121+
raise HTTPException(status_code=400, detail="interval must be one of: day, week, month")
122+
123+
start, end = resolve_required_window(start, end)
124+
normalized_hashtag = normalize_hashtags(hashtag)
125+
hashtag_rows = await fetch_hashtag_stats(
126+
start=start,
127+
end=end,
128+
hashtag=normalized_hashtag,
129+
limit=limit,
130+
offset=offset,
131+
)
132+
trend_rows = await fetch_hashtag_trends(
133+
start=start,
134+
end=end,
135+
interval=interval,
136+
hashtag=normalized_hashtag,
137+
limit=limit,
138+
offset=offset,
139+
)
140+
hashtags = [HashtagStat(**row) for row in hashtag_rows]
141+
trends = [HashtagTrend(**row) for row in trend_rows]
142+
return HashtagStatsResponse(
143+
count=len(hashtags),
144+
start=start,
145+
end=end,
146+
hashtag=normalized_hashtag,
147+
interval=interval,
148+
limit=limit,
149+
offset=offset,
150+
hashtags=hashtags,
151+
trends=trends,
152+
)
153+
154+
155+
class EditorStatsController(Controller):
156+
path = "/editor-stats"
157+
158+
@get()
159+
async def get_editor_stats(
160+
self,
161+
start: Annotated[
162+
datetime | None, Parameter(description="Inclusive UTC lower bound (ISO 8601). If omitted, no lower bound.")
163+
] = None,
164+
end: Annotated[
165+
datetime | None,
166+
Parameter(description="Exclusive UTC upper bound (ISO 8601). Defaults to now if start is set."),
167+
] = None,
168+
limit: Annotated[int, Parameter(ge=1, le=1000, description="Page size (1-1000).")] = 100,
169+
offset: Annotated[int, Parameter(ge=0, description="Page offset.")] = 0,
170+
) -> EditorStatsResponse:
171+
start, end = resolve_optional_window(start, end)
172+
rows = await fetch_editor_stats(
173+
start=start,
174+
end=end,
175+
limit=limit,
176+
offset=offset,
177+
)
178+
editors = [EditorStat(**row) for row in rows]
179+
return EditorStatsResponse(
180+
count=len(editors),
181+
start=start,
182+
end=end,
183+
limit=limit,
184+
offset=offset,
185+
editors=editors,
186+
)
187+
188+
189+
v1_router = Router(
190+
path="/api/v1",
191+
route_handlers=[StatsController, HashtagStatsController, EditorStatsController],
192+
)

0 commit comments

Comments
 (0)