Compare commits

...

2 Commits

Author SHA1 Message Date
00b74e8782 Move towards a more object-oriented API 2023-02-18 04:21:28 +01:00
70a069fe95 Support multiple calendars
This implied switching to a different caching approach.
2023-02-18 04:21:28 +01:00
7 changed files with 698 additions and 682 deletions

1
.gitignore vendored
View File

@ -6,3 +6,4 @@ docker-compose.yml
dist/ dist/
node_modules/ node_modules/
yarn*.log yarn*.log
*.yml

7
config.yml.example Normal file
View File

@ -0,0 +1,7 @@
calendars:
- name: "Events"
url: "https://example.org/events.ics"
- name: "More events"
url: "https://example.com/more-events.ics"
default_color: "#aabbcc"

View File

@ -3,8 +3,6 @@ import locale
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi_cache import FastAPICache
from fastapi_cache.backends.inmemory import InMemoryBackend
from fabcal.routers import api_v1, frontend from fabcal.routers import api_v1, frontend
@ -29,8 +27,3 @@ app.add_middleware(
locale.setlocale(locale.LC_TIME, locale.getlocale()) locale.setlocale(locale.LC_TIME, locale.getlocale())
@app.on_event("startup")
async def startup():
FastAPICache.init(InMemoryBackend())

View File

@ -1,53 +1,112 @@
import asyncio
import os import os
from collections import OrderedDict from collections import OrderedDict
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from typing import List from typing import Dict, List, NamedTuple
import aiohttp import aiohttp
import recurring_ical_events import recurring_ical_events
import yaml
from fastapi_cache.decorator import cache from asyncache import cached
from icalendar import Calendar, vText from cachetools import TTLCache, LRUCache
from icalendar import Calendar
from fabcal.models import CalendarEvent from fabcal.models import CalendarEvent
def sanitize(data: str): class ConfiguredCalendar(NamedTuple):
# sanitize input from random source name: str
cal = Calendar.from_ical(data) url: str
default_color: str = None
# name needs to be fixed
cal["X-WR-CALNAME"] = vText(b"FabLab-Termine")
return cal.to_ical()
def get_calendar_url(): class CalendarClient:
url = os.environ["CALENDAR_URL"] def __init__(self, url: str):
self.url = url
# convenience feature async def get(self, session):
url = url.replace("webcal://", "https://") async with session.get(self.url) as response:
return url
# caching strings works better than caching calendar objects
@cache(expire=120)
async def get_data() -> str:
async with aiohttp.ClientSession() as session:
async with session.get(get_calendar_url()) as response:
response.raise_for_status() response.raise_for_status()
assert response.content_type.lower() == "text/calendar" assert response.content_type.lower() == "text/calendar"
return sanitize(await response.text()) return await response.text()
# this will be cached permanently, i.e., the server process needs to be restarted to apply config changes
@cached(LRUCache(100))
def read_calendars_from_config_file():
with open("config.yml") as f:
data = yaml.safe_load(f)
for calendar in data["calendars"]:
yield ConfiguredCalendar(**calendar)
class CombinedCalendarClient:
def __init__(self, configured_calendars: List[ConfiguredCalendar]):
# make sure it's a list since read_calendars_from_config_file() returns an iterator
self.configured_calendars = list(configured_calendars)
async def fetch_calendars(self) -> Dict[ConfiguredCalendar, str]:
async with aiohttp.ClientSession() as session:
calendar_clients = [CalendarClient(calendar.url) for calendar in self.configured_calendars]
responses = await asyncio.gather(*[calendar.get(session) for calendar in calendar_clients])
return dict(zip(self.configured_calendars, responses))
@staticmethod
def combine_calendars(data: Dict[ConfiguredCalendar, str]) -> Calendar:
combined_calendar = Calendar()
combined_calendar.add("prodid", "-//FabCal//NONSGML//EN")
combined_calendar.add("version", "2.0")
combined_calendar.add("x-wr-calname", "FabLab Altmühlfranken e.V.")
# TODO: normalize timezones of calendar events
for configured_calendar, ical_str in data.items():
cal = Calendar.from_ical(ical_str)
# check for a calendar color (e.g., Nextcloud has such a feature)
# events that don't have a color assigned will be assigned this color unless a color was configured
default_color = configured_calendar.default_color
if not default_color:
try:
# note: for some reason, getattr doesn't work
default_color = cal["x-apple-calendar-color"]
except KeyError:
default_color = None
# we don't copy anything but events from the
for event in cal.walk("VEVENT"):
# if no color has been configured in the event, we
if "color" not in event:
event["color"] = default_color
combined_calendar.add_component(event)
return combined_calendar
async def fetch_and_combine_calendars(self) -> Calendar:
return self.combine_calendars(await self.fetch_calendars())
@cached(TTLCache(maxsize=100, ttl=int(os.environ.get("FABCAL_CACHE_EXPIRE", 120))))
async def get_data() -> Calendar:
client = CombinedCalendarClient(read_calendars_from_config_file())
combined_calendar = await client.fetch_and_combine_calendars()
return combined_calendar
def get_tzinfo(): def get_tzinfo():
return timezone(timedelta(hours=1)) return timezone(timedelta(hours=1))
def get_events_from_calendar_string(cal: Calendar) -> List[CalendarEvent]: def get_events_from_calendar(cal: Calendar) -> List[CalendarEvent]:
""" """
Generate list of events from calendar vevents. Generate list of events from calendar vevents.
Expands recurring events for +- one year. Expands recurring events for +- one year.
@ -106,9 +165,7 @@ def get_events_from_calendar_string(cal: Calendar) -> List[CalendarEvent]:
async def get_future_events(): async def get_future_events():
cal = Calendar.from_ical(await get_data()) events = get_events_from_calendar(await get_data())
events = get_events_from_calendar_string(cal)
today = datetime.today().date() today = datetime.today().date()

View File

@ -10,7 +10,7 @@ router = APIRouter()
@router.get("/events.ics") @router.get("/events.ics")
async def events_ics(): async def events_ics():
return Response( return Response(
await get_data(), (await get_data()).to_ical(True),
headers={ headers={
"content-type": "text/calendar", "content-type": "text/calendar",
}, },

1245
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -15,9 +15,10 @@ fastapi = "^0.75.0"
uvicorn = {extras = ["standard"], version = "^0.17.6"} uvicorn = {extras = ["standard"], version = "^0.17.6"}
Jinja2 = "^3.1.0" Jinja2 = "^3.1.0"
Babel = "^2.9.1" Babel = "^2.9.1"
fastapi-cache2 = "^0.1.8"
aiohttp = {extras = ["speedups"], version = "^3.8.1"} aiohttp = {extras = ["speedups"], version = "^3.8.1"}
recurring-ical-events = "^1.0.1-beta.0" recurring-ical-events = "^1.0.1-beta.0"
pyyaml = "^6.0"
asyncache = "^0.3.1"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
black = "^22.1.0" black = "^22.1.0"