diff --git a/backend/pyproject.toml b/backend/pyproject.toml index fd84b741..5b4de57c 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "fastapi~=0.110.1", "google-auth~=2.29.0", "httpx~=0.27.0", + "icalendar~=5.0.12", "piccolo==0.119.0", "uvicorn~=0.29.0", "requests~=2.31.0" @@ -26,6 +27,7 @@ optional-dependencies.dev = [ "respx~=0.21.1", "ruff~=0.3.5", "time-machine~=2.14.1", + "types-icalendar~=5.0.0", "types-requests~=2.31.0" ] diff --git a/backend/requirements.txt b/backend/requirements.txt index 74da6789..449d9b78 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -43,6 +43,7 @@ h11==0.14.0 httpcore==1.0.5 # via httpx httpx==0.27.0 +icalendar==5.0.12 idna==3.6 # via # anyio @@ -74,9 +75,15 @@ pydantic==1.10.15 # via # fastapi # piccolo +python-dateutil==2.9.0.post0 + # via icalendar +pytz==2024.1 + # via icalendar requests==2.31.0 rsa==4.9 # via google-auth +six==1.16.0 + # via python-dateutil sniffio==1.3.1 # via # anyio diff --git a/backend/src/routes/leagues.py b/backend/src/routes/leagues.py index d8e4ee83..c863c0a1 100644 --- a/backend/src/routes/leagues.py +++ b/backend/src/routes/leagues.py @@ -1,7 +1,9 @@ import asyncio from typing import Awaitable, Iterable +import icalendar from fastapi import Depends, Path +from fastapi.responses import PlainTextResponse from fastapi.routing import APIRouter from ..database import ( @@ -166,6 +168,59 @@ async def get_league_events( return events +def generate_event_calendar_description(event: EventWithLeagueDetails) -> str: + description = "" + + if event.organiser: + description += f"Organised by {event.organiser}\n" + if event.part_of: + description += f"Part of {event.part_of}\n" + if event.more_information: + description += f"\n{event.more_information}\n" + if event.website: + description += f"\nFor more information, see the event website: {event.website}" + + if event.results_links: + description += "\nResults:\n" + for destination, link in event.results_links.items(): + description += f"\n {destination}: {link}" + + return description + + +@router.get("/{league_name}/events/calendar") +async def get_league_events_calendar( + league_name: str = Path( + title="League Name", + description="Name of the league to get the results for", + example="Sprintelope 2021", + ), +) -> PlainTextResponse: + league, events = await asyncio.gather( + Leagues.get_by_name(league_name), + Events.get_by_league(league_name), + ) + + if not league: + raise HTTP_404(f"Couldn't find league with name `{league_name}`") + + calendar = icalendar.Calendar() + calendar["name"] = league.name + + for event in events: + calendar_event = icalendar.Event() + calendar_event.add("summary", f"{league.name} - {event.name}") + calendar_event.add("description", generate_event_calendar_description(event)) + calendar_event.add("dtstart", event.date) + calendar_event.add("location", event.name) + calendar_event.add("url", event.website) + calendar_event.add("transp", "TRANSPARENT") # doesn't show as busy + + calendar.add_component(calendar_event) + + return PlainTextResponse(calendar.to_ical(), media_type="text/calendar") + + def get_results_for_event( event: LeagueEvent, league_class: LeagueClass ) -> Awaitable[Iterable[Result]]: diff --git a/backend/src/types/ics.pyi b/backend/src/types/ics.pyi new file mode 100644 index 00000000..254416f5 --- /dev/null +++ b/backend/src/types/ics.pyi @@ -0,0 +1,22 @@ +from datetime import date +from typing import Iterable + +class Calendar: + events: set[Event] + def __init__(self) -> None: ... + def serialize_iter(self) -> Iterable[str]: ... + +class Event: + def __init__( + self, + name: str | None, + begin: date | None, + description: str | None, + location: str | None, + url: str | None, + organizer: Organizer | None, + ) -> None: ... + def make_all_day(self) -> None: ... + +class Organizer: + def __init__(self, email: str | None, common_name: str | None) -> None: ...