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/
node_modules/
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.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi_cache import FastAPICache
from fastapi_cache.backends.inmemory import InMemoryBackend
from fabcal.routers import api_v1, frontend
@ -29,8 +27,3 @@ app.add_middleware(
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
from collections import OrderedDict
from datetime import date, datetime, timedelta, timezone
from typing import List
from typing import Dict, List, NamedTuple
import aiohttp
import recurring_ical_events
import yaml
from fastapi_cache.decorator import cache
from icalendar import Calendar, vText
from asyncache import cached
from cachetools import TTLCache, LRUCache
from icalendar import Calendar
from fabcal.models import CalendarEvent
def sanitize(data: str):
# sanitize input from random source
cal = Calendar.from_ical(data)
# name needs to be fixed
cal["X-WR-CALNAME"] = vText(b"FabLab-Termine")
return cal.to_ical()
class ConfiguredCalendar(NamedTuple):
name: str
url: str
default_color: str = None
def get_calendar_url():
url = os.environ["CALENDAR_URL"]
class CalendarClient:
def __init__(self, url: str):
self.url = url
# convenience feature
url = url.replace("webcal://", "https://")
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:
async def get(self, session):
async with session.get(self.url) as response:
response.raise_for_status()
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():
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.
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():
cal = Calendar.from_ical(await get_data())
events = get_events_from_calendar_string(cal)
events = get_events_from_calendar(await get_data())
today = datetime.today().date()

View File

@ -10,7 +10,7 @@ router = APIRouter()
@router.get("/events.ics")
async def events_ics():
return Response(
await get_data(),
(await get_data()).to_ical(True),
headers={
"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"}
Jinja2 = "^3.1.0"
Babel = "^2.9.1"
fastapi-cache2 = "^0.1.8"
aiohttp = {extras = ["speedups"], version = "^3.8.1"}
recurring-ical-events = "^1.0.1-beta.0"
pyyaml = "^6.0"
asyncache = "^0.3.1"
[tool.poetry.dev-dependencies]
black = "^22.1.0"