Support multiple calendars
This implied switching to a different caching approach.
This commit is contained in:
		
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -6,3 +6,4 @@ docker-compose.yml
 | 
				
			|||||||
dist/
 | 
					dist/
 | 
				
			||||||
node_modules/
 | 
					node_modules/
 | 
				
			||||||
yarn*.log
 | 
					yarn*.log
 | 
				
			||||||
 | 
					*.yml
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										7
									
								
								config.yml.example
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								config.yml.example
									
									
									
									
									
										Normal 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"
 | 
				
			||||||
@@ -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())
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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:
 | 
					 | 
				
			||||||
            response.raise_for_status()
 | 
					 | 
				
			||||||
            assert response.content_type.lower() == "text/calendar"
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
            return sanitize(await response.text())
 | 
					        async def get(url: str) -> str:
 | 
				
			||||||
 | 
					            async with session.get(url) as response:
 | 
				
			||||||
 | 
					                response.raise_for_status()
 | 
				
			||||||
 | 
					                assert response.content_type.lower() == "text/calendar"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                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)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
									
									
									
								
							
							
						
						
									
										1245
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -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"
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user