Compare commits
No commits in common. "00b74e87826c805cf5b81c7edb690a498c9f5767" and "5d58ba89c475e84aa7db5fc0a15ae6ad07d58404" have entirely different histories.
00b74e8782
...
5d58ba89c4
1
.gitignore
vendored
1
.gitignore
vendored
@ -6,4 +6,3 @@ docker-compose.yml
|
|||||||
dist/
|
dist/
|
||||||
node_modules/
|
node_modules/
|
||||||
yarn*.log
|
yarn*.log
|
||||||
*.yml
|
|
||||||
|
@ -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"
|
|
@ -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())
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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
1245
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||||
|
Loading…
Reference in New Issue
Block a user