Compare commits

..

No commits in common. "00b74e87826c805cf5b81c7edb690a498c9f5767" and "5d58ba89c475e84aa7db5fc0a15ae6ad07d58404" have entirely different histories.

7 changed files with 682 additions and 698 deletions

1
.gitignore vendored
View File

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

View File

@ -1,7 +0,0 @@
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,6 +3,8 @@ 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
@ -27,3 +29,8 @@ 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,112 +1,53 @@
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 Dict, List, NamedTuple from typing import List
import aiohttp import aiohttp
import recurring_ical_events import recurring_ical_events
import yaml
from asyncache import cached from fastapi_cache.decorator import cache
from cachetools import TTLCache, LRUCache from icalendar import Calendar, vText
from icalendar import Calendar
from fabcal.models import CalendarEvent from fabcal.models import CalendarEvent
class ConfiguredCalendar(NamedTuple): def sanitize(data: str):
name: str # sanitize input from random source
url: str cal = Calendar.from_ical(data)
default_color: str = None
# name needs to be fixed
cal["X-WR-CALNAME"] = vText(b"FabLab-Termine")
return cal.to_ical()
class CalendarClient: def get_calendar_url():
def __init__(self, url: str): url = os.environ["CALENDAR_URL"]
self.url = url
async def get(self, session): # convenience feature
async with session.get(self.url) as response: 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:
response.raise_for_status() response.raise_for_status()
assert response.content_type.lower() == "text/calendar" assert response.content_type.lower() == "text/calendar"
return await response.text() return sanitize(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(cal: Calendar) -> List[CalendarEvent]: def get_events_from_calendar_string(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.
@ -165,7 +106,9 @@ def get_events_from_calendar(cal: Calendar) -> List[CalendarEvent]:
async def get_future_events(): async def get_future_events():
events = get_events_from_calendar(await get_data()) cal = Calendar.from_ical(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()).to_ical(True), await get_data(),
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,10 +15,9 @@ 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"