174 lines
6.4 KiB
Python
174 lines
6.4 KiB
Python
"""Loads the scraped events JSON file(s) and keeps them in memory.
|
|
|
|
Files are treated as read-only. Call ``reload()`` to pick up a fresh
|
|
scrape without restarting the server.
|
|
|
|
Two files are read:
|
|
- the main data file (upcoming events, produced by a plain
|
|
``scraper.py`` run) - required, resolved from DATA_FILE.
|
|
- the archive file (past events, produced by ``scraper.py --archive``)
|
|
- optional, resolved from ARCHIVE_DATA_FILE. Missing archive file
|
|
just means no archived events are available yet.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
from datetime import date
|
|
from pathlib import Path
|
|
from threading import Lock
|
|
from typing import List, Optional
|
|
|
|
from models import Event
|
|
|
|
# Defaults: the fundi-*-output.json files sitting in the repo root, one
|
|
# directory above this file. Override with the DATA_FILE / ARCHIVE_DATA_FILE
|
|
# env vars.
|
|
_DEFAULT_DATA_FILE = Path(__file__).resolve().parent.parent / "fundi-scraped-output.json"
|
|
_DEFAULT_ARCHIVE_DATA_FILE = Path(__file__).resolve().parent.parent / "fundi-archive-output.json"
|
|
|
|
|
|
class EventStore:
|
|
def __init__(self, path: Optional[os.PathLike] = None, archive_path: Optional[os.PathLike] = None):
|
|
self.path = Path(path or os.environ.get("DATA_FILE") or _DEFAULT_DATA_FILE)
|
|
self.archive_path = Path(archive_path or os.environ.get("ARCHIVE_DATA_FILE") or _DEFAULT_ARCHIVE_DATA_FILE)
|
|
self._lock = Lock()
|
|
self._events: List[Event] = []
|
|
self._current_count = 0
|
|
self._archive_count = 0
|
|
self.reload()
|
|
|
|
@staticmethod
|
|
def _load_file(path: Path) -> List[Event]:
|
|
if not path.exists():
|
|
return []
|
|
with path.open(encoding="utf-8") as fh:
|
|
raw = json.load(fh)
|
|
return [Event.model_validate(item) for item in raw]
|
|
|
|
def reload(self) -> int:
|
|
"""Re-read the data file(s) from disk. Returns the number of events loaded."""
|
|
if not self.path.exists():
|
|
raise FileNotFoundError(f"Data file not found: {self.path}")
|
|
current = self._load_file(self.path)
|
|
archive = self._load_file(self.archive_path)
|
|
|
|
# Merge, de-duplicating on (date, name) in case an event that was
|
|
# scraped while upcoming was later also picked up by an archive
|
|
# scrape - the current-file copy wins.
|
|
seen = {(event.event_date, event.event_name) for event in current}
|
|
merged = list(current)
|
|
for event in archive:
|
|
key = (event.event_date, event.event_name)
|
|
if key not in seen:
|
|
merged.append(event)
|
|
seen.add(key)
|
|
|
|
with self._lock:
|
|
self._events = merged
|
|
self._current_count = len(current)
|
|
self._archive_count = len(merged) - len(current)
|
|
return len(merged)
|
|
|
|
def all(self) -> List[Event]:
|
|
with self._lock:
|
|
return list(self._events)
|
|
|
|
def get(self, index: int) -> Optional[Event]:
|
|
with self._lock:
|
|
if 0 <= index < len(self._events):
|
|
return self._events[index]
|
|
return None
|
|
|
|
@property
|
|
def current_count(self) -> int:
|
|
with self._lock:
|
|
return self._current_count
|
|
|
|
@property
|
|
def archive_count(self) -> int:
|
|
with self._lock:
|
|
return self._archive_count
|
|
|
|
|
|
def parse_event_date(value: str) -> Optional[date]:
|
|
"""Parse the scraper's ``dd.mm.yy`` date strings. Returns None if unparseable."""
|
|
try:
|
|
day, month, year = (int(part) for part in value.strip().split("."))
|
|
except (ValueError, AttributeError):
|
|
return None
|
|
if year < 100:
|
|
year += 2000
|
|
try:
|
|
return date(year, month, day)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def iter_artist_names(event: Event):
|
|
for group in event.event_artists:
|
|
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_artist_schedules(items, sort: Optional[str]):
|
|
"""Sort (artist_name, dates) pairs per the ``?sort=`` query param.
|
|
|
|
"asc"/"desc" order by each artist's *latest* (most recent) date, since
|
|
an artist has many dates and not just one to sort on; artists with no
|
|
parseable date always sort last, regardless of direction. "az"/"za"
|
|
order alphabetically (case-insensitive) by name. None (or any other
|
|
value) defaults to "az", matching the plain-list behavior before sort
|
|
support existed.
|
|
"""
|
|
items = list(items)
|
|
if sort in ("asc", "desc"):
|
|
def _latest(dates) -> Optional[date]:
|
|
parsed = [d for d in (parse_event_date(x) for x in dates) if d is not None]
|
|
return max(parsed) if parsed else None
|
|
|
|
with_latest = [(name, dates, _latest(dates)) for name, dates in items]
|
|
dated = sorted(
|
|
(item for item in with_latest if item[2] is not None),
|
|
key=lambda item: item[2],
|
|
reverse=(sort == "desc"),
|
|
)
|
|
undated = [(name, dates) for name, dates, latest in with_latest if latest is None]
|
|
return [(name, dates) for name, dates, _ in dated] + undated
|
|
return sorted(items, key=lambda item: item[0].lower(), reverse=(sort == "za"))
|
|
|
|
|
|
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
|