diff --git a/README.md b/README.md index fcc01d4..2b898d5 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ DATA_FILE=/path/to/events.json ARCHIVE_DATA_FILE=/path/to/archive.json uvicorn m | `GET` | `/events` | List events, with optional filters (below) | | `GET` | `/events/archive` | Past events only — shorthand for `/events?upcoming=false` | | `GET` | `/events/{index}` | A single event by its 0-based position in the merged list; `404` if out of range | -| `GET` | `/artists` | Deduplicated, case-insensitively sorted list of all artist names | +| `GET` | `/artists` | Deduplicated, case-insensitively sorted list of all artists, each with every date they appear on | | `GET` | `/calendar.ics` | iCalendar/webcal feed of all events — subscribe from any calendar app | | `POST` | `/reload` | Re-read the data file(s) from disk (call after re-running the scraper); rate-limited (below); `500` if the main file is missing | @@ -142,6 +142,7 @@ same filters except `upcoming` (it's always `false`). | `artist` | string | Case-insensitive substring match on any artist name in the line-up | | `date` | string | Exact match on the raw `event_date` string (`dd.mm.yy`) | | `upcoming` | bool | `true` = event date is today or later, `false` = past events (events with an unparseable date are excluded) | +| `sort` | string | `asc`/`desc` = chronological by `event_date` (events with an unparseable date always sort last, regardless of direction); `az`/`za` = alphabetical by `event_name` (case-insensitive) | ### Response shapes @@ -155,7 +156,15 @@ same filters except `upcoming` (it's always `false`). ``` `GET /events/{index}` returns a single event object (same schema as the scraper -output). `GET /artists` returns a plain JSON array of strings. +output). `GET /artists` returns each artist once with every date (past or +upcoming) they're in 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"] } +] +``` ### Scrape schedule reporting @@ -238,6 +247,9 @@ curl 'https://fundi.api.example.com/events?artist=randali&upcoming=true' # Everything on a given night curl 'https://fundi.api.example.com/events?date=05.09.26' +# Soonest events first +curl 'https://fundi.api.example.com/events?sort=asc' + # Past events (archive) curl https://fundi.api.example.com/events/archive diff --git a/api/README.md b/api/README.md index a4a2698..be6bdbb 100644 --- a/api/README.md +++ b/api/README.md @@ -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:///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"] } +] +``` diff --git a/api/__pycache__/calendar_feed.cpython-313.pyc b/api/__pycache__/calendar_feed.cpython-313.pyc new file mode 100644 index 0000000..42ed6ac Binary files /dev/null and b/api/__pycache__/calendar_feed.cpython-313.pyc differ diff --git a/api/__pycache__/datasource.cpython-313.pyc b/api/__pycache__/datasource.cpython-313.pyc index 41b5034..fc486b1 100644 Binary files a/api/__pycache__/datasource.cpython-313.pyc and b/api/__pycache__/datasource.cpython-313.pyc differ diff --git a/api/__pycache__/main.cpython-313.pyc b/api/__pycache__/main.cpython-313.pyc index 193ff22..e3a1912 100644 Binary files a/api/__pycache__/main.cpython-313.pyc and b/api/__pycache__/main.cpython-313.pyc differ diff --git a/api/__pycache__/models.cpython-313.pyc b/api/__pycache__/models.cpython-313.pyc index 9198c7a..23a11cf 100644 Binary files a/api/__pycache__/models.cpython-313.pyc and b/api/__pycache__/models.cpython-313.pyc differ diff --git a/api/__pycache__/rate_limit.cpython-313.pyc b/api/__pycache__/rate_limit.cpython-313.pyc new file mode 100644 index 0000000..9022b8b Binary files /dev/null and b/api/__pycache__/rate_limit.cpython-313.pyc differ diff --git a/api/__pycache__/scrape_schedule.cpython-313.pyc b/api/__pycache__/scrape_schedule.cpython-313.pyc index fac45a5..3768ea5 100644 Binary files a/api/__pycache__/scrape_schedule.cpython-313.pyc and b/api/__pycache__/scrape_schedule.cpython-313.pyc differ diff --git a/api/datasource.py b/api/datasource.py index e439dcb..9de4036 100644 --- a/api/datasource.py +++ b/api/datasource.py @@ -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 diff --git a/api/main.py b/api/main.py index 9bc6b84..cf685ee 100644 --- a/api/main.py +++ b/api/main.py @@ -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) diff --git a/api/models.py b/api/models.py index 8dbabd8..e0f0561 100644 --- a/api/models.py +++ b/api/models.py @@ -21,3 +21,8 @@ class Event(BaseModel): class EventList(BaseModel): count: int events: List[Event] + + +class ArtistSchedule(BaseModel): + artist_name: str + dates: List[str]