Support multiple calendars

This implied switching to a different caching approach.
This commit is contained in:
Fabian Müller 2023-02-18 04:03:55 +01:00
parent 5d58ba89c4
commit 70a069fe95
7 changed files with 679 additions and 679 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,46 +1,87 @@
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
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(): # this is not ideal, since it will read the file (at most) every 2 minutes, but there is no good way to cache the
url = os.environ["CALENDAR_URL"] # settings, e.g., by storing it in the FastAPI object
def read_calendars_from_config_file():
with open("config.yml") as f:
data = yaml.safe_load(f)
# convenience feature for calendar in data["calendars"]:
url = url.replace("webcal://", "https://") yield ConfiguredCalendar(**calendar)
return url
# caching strings works better than caching calendar objects @cached(TTLCache(maxsize=100, ttl=int(os.environ.get("FABCAL_CACHE_EXPIRE", 120))))
@cache(expire=120) async def get_data() -> Dict[ConfiguredCalendar, str]:
async def get_data() -> str: calendars = list(read_calendars_from_config_file())
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(get_calendar_url()) as response:
async def get(url: str) -> str:
async with session.get(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()
responses = await asyncio.gather(*[get(calendar.url) for calendar in calendars])
return dict(zip(calendars, responses))
def combined_calendar(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
def get_tzinfo(): def get_tzinfo():
@ -106,7 +147,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()) cal = combined_calendar(await get_data())
events = get_events_from_calendar_string(cal) events = get_events_from_calendar_string(cal)

View File

@ -1,7 +1,7 @@
from fastapi import APIRouter from fastapi import APIRouter
from starlette.responses import Response from starlette.responses import Response
from fabcal.calendar_client import get_data from fabcal.calendar_client import combined_calendar, get_data
router = APIRouter() router = APIRouter()
@ -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(), combined_calendar(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"