added sorting and artist dates

This commit is contained in:
Ebbe Baß
2026-09-09 14:10:15 +02:00
parent b2468cc083
commit 5d56df1af8
11 changed files with 96 additions and 11 deletions
+18 -1
View File
@@ -75,12 +75,14 @@ RELOAD_MIN_INTERVAL_SECONDS=30 uvicorn main:app
| GET | `/events` | List events, with optional filters (see below) |
| GET | `/events/archive` | Past events only — shorthand for `/events?upcoming=false` |
| GET | `/events/{index}` | Single event by its position in the merged list (0-based) |
| GET | `/artists` | Deduplicated, sorted list of all artist names |
| GET | `/artists` | Deduplicated, sorted list of all artists with every date they appear on |
| GET | `/calendar.ics` | iCalendar/webcal feed of all events — subscribe with `webcal://<host>/calendar.ics` |
| POST | `/reload` | Re-read the data file(s) from disk (after a fresh scrape) - rate-limited, see below |
### `/events` query parameters
`/events/archive` accepts the same parameters except `upcoming` (always `false`).
| Param | Type | Meaning |
|------------|--------|---------|
| `free` | bool | Only free / only paid events |
@@ -88,11 +90,26 @@ RELOAD_MIN_INTERVAL_SECONDS=30 uvicorn main:app
| `artist` | string | Case-insensitive substring match on any artist name |
| `date` | string | Exact match on the raw date string (`dd.mm.yy`) |
| `upcoming` | bool | `true` = today or later, `false` = past events |
| `sort` | string | `asc`/`desc` = chronological by event date (unparseable dates sort last either way); `az`/`za` = alphabetical by event name (case-insensitive) |
Examples:
```bash
curl 'http://127.0.0.1:8000/events?free=true'
curl 'http://127.0.0.1:8000/events?artist=randali&upcoming=true'
curl 'http://127.0.0.1:8000/events?sort=asc'
curl 'http://127.0.0.1:8000/events?sort=za'
curl 'http://127.0.0.1:8000/events/0'
```
### `/artists` response shape
Each artist appears once, with every date (past or upcoming) they're on the
line-up for, sorted chronologically:
```json
[
{ "artist_name": "Bizarre", "dates": ["05.09.26", "12.10.26"] },
{ "artist_name": "Skkin Velvet", "dates": ["04.09.26"] }
]
```
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+35
View File
@@ -109,3 +109,38 @@ def iter_artist_names(event: Event):
for artist in group:
if artist.artist_name:
yield artist.artist_name
def sort_events(events: List[Event], sort: Optional[str]) -> List[Event]:
"""Sort events per the ``?sort=`` query param.
"asc"/"desc" order chronologically by ``event_date``; events with an
unparseable date always sort last, regardless of direction. "az"/"za"
order alphabetically (case-insensitive) by ``event_name``. Any other
value (including None) leaves the events in their existing order.
"""
if sort in ("asc", "desc"):
with_parsed = [(e, parse_event_date(e.event_date)) for e in events]
dated = sorted(
(item for item in with_parsed if item[1] is not None),
key=lambda item: item[1],
reverse=(sort == "desc"),
)
undated = [e for e, parsed in with_parsed if parsed is None]
return [e for e, _ in dated] + undated
if sort in ("az", "za"):
return sorted(events, key=lambda e: e.event_name.lower(), reverse=(sort == "za"))
return events
def sort_date_strings(dates) -> List[str]:
"""Sort raw ``dd.mm.yy`` date strings chronologically, de-duplicated.
Unparseable strings sort last (alphabetically among themselves).
"""
unique = set(dates)
dated = sorted(
(d for d in unique if parse_event_date(d) is not None), key=parse_event_date
)
undated = sorted(d for d in unique if parse_event_date(d) is None)
return dated + undated
+24 -8
View File
@@ -9,17 +9,23 @@ Run with:
import datetime
import os
from typing import List, Optional
from collections import defaultdict
from typing import List, Literal, Optional
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import PlainTextResponse
from calendar_feed import build_ics
from datasource import EventStore, iter_artist_names, parse_event_date
from models import Event, EventList
from datasource import EventStore, iter_artist_names, parse_event_date, sort_date_strings, sort_events
from models import ArtistSchedule, Event, EventList
from rate_limit import Cooldown
from scrape_schedule import discover_cron_expr, get_schedule_info
SortOrder = Literal["asc", "desc", "az", "za"]
_SORT_DESCRIPTION = (
"Sort order: asc/desc by event date, az/za alphabetically by event name"
)
app = FastAPI(
title="Fundi Scraper API",
description="REST API over the scraped upcoming/past events at the Fundbureau Hamburg.",
@@ -118,6 +124,7 @@ def list_events(
artist: Optional[str] = Query(None, description="Case-insensitive substring match on any artist name"),
date: Optional[str] = Query(None, description="Exact match on the raw event date (dd.mm.yy)"),
upcoming: Optional[bool] = Query(None, description="true = today or later, false = past events"),
sort: Optional[SortOrder] = Query(None, description=_SORT_DESCRIPTION),
):
today = datetime.date.today()
events = [
@@ -125,16 +132,23 @@ def list_events(
for e in store.all()
if _matches(e, free, name, artist, date, upcoming, today)
]
events = sort_events(events, sort)
return EventList(count=len(events), events=events)
@app.get("/artists", response_model=List[str])
@app.get("/artists", response_model=List[ArtistSchedule])
def list_artists():
"""Deduplicated, sorted list of all artist names across all events."""
names = set()
"""Deduplicated, sorted list of all artists, each with every date
(past or upcoming) they appear in the line-up.
"""
appearances: dict = defaultdict(set)
for event in store.all():
names.update(iter_artist_names(event))
return sorted(names, key=str.lower)
for artist_name in iter_artist_names(event):
appearances[artist_name].add(event.event_date)
return [
ArtistSchedule(artist_name=artist_name, dates=sort_date_strings(dates))
for artist_name, dates in sorted(appearances.items(), key=lambda kv: kv[0].lower())
]
@app.get("/events/archive", response_model=EventList)
@@ -143,6 +157,7 @@ def list_archive_events(
name: Optional[str] = Query(None, description="Case-insensitive substring match on event name"),
artist: Optional[str] = Query(None, description="Case-insensitive substring match on any artist name"),
date: Optional[str] = Query(None, description="Exact match on the raw event date (dd.mm.yy)"),
sort: Optional[SortOrder] = Query(None, description=_SORT_DESCRIPTION),
):
"""Past events only - shorthand for ``/events?upcoming=false``."""
today = datetime.date.today()
@@ -151,6 +166,7 @@ def list_archive_events(
for e in store.all()
if _matches(e, free, name, artist, date, upcoming=False, today=today)
]
events = sort_events(events, sort)
return EventList(count=len(events), events=events)
+5
View File
@@ -21,3 +21,8 @@ class Event(BaseModel):
class EventList(BaseModel):
count: int
events: List[Event]
class ArtistSchedule(BaseModel):
artist_name: str
dates: List[str]