fetch calendar as background task
1 file changed, 214 insertions(+), 68 deletions(-)
changed files
M mycal.py → mycal.py
@@ -3,7 +3,8 @@ from icalendar.cal import Calendar, FreeBusy import icalendar import urllib.parse -import requests +import aiohttp +import asyncio from quart import Quart from datetime import date, datetime, timedelta, time import zoneinfo@@ -12,6 +13,8 @@ from danoan.toml_dataclass import TomlDataClassIO from os import environ import typing import traceback +import logging +from asyncio import Condition @dataclass class CalendarConfig(TomlDataClassIO):@@ -24,6 +27,7 @@ class Config(TomlDataClassIO): name: str email: str timezone: str + update_interval: int = 3600 calendars: typing.List[CalendarConfig] = field(default_factory=list) def load_config(file_path):@@ -36,23 +40,34 @@ config = load_config(environ.get("CONFIG_FILE", "config.toml")) tz = zoneinfo.ZoneInfo(config.timezone) utc = zoneinfo.ZoneInfo("UTC") -def fetch_calendar(calendar_url): - return requests.get(calendar_url).content +calendar_cache = { + 'last_updated': None, + 'busy_ical': "", + 'events_ical': "" +} + +next_update_seconds = 0 +cache_condition = Condition() + +async def fetch_calendar_async(calendar_url): + async with aiohttp.ClientSession() as session: + async with session.get(calendar_url) as response: + return await response.read() def read_calendar_file(calendar_file): with open(calendar_file, 'rb') as f: return f.read() -def get_calendar(calendar_config): +async def get_calendar_async(calendar_config): if 'file' in calendar_config and calendar_config['file']: return read_calendar_file(calendar_config['file']) + elif 'url' in calendar_config and calendar_config['url']: + u = urllib.parse.urlsplit(calendar_config['url']) + if u.scheme == "webcal": + u = u._replace(scheme="https") + return await fetch_calendar_async(urllib.parse.urlunsplit(u)) else: - if 'url' in calendar_config and calendar_config['url']: - u = urllib.parse.urlsplit(calendar_config['url']) - if u.scheme == "webcal": - u = u._replace(scheme="https") - return fetch_calendar(urllib.parse.urlunsplit(u)) - raise ValueError("Calendar URL not configured.") + raise ValueError("Calendar URL or file not configured.") def fixup_date(dt): if type(dt) == date:@@ -66,100 +81,231 @@ def looks_tentative(component): summary = str(component.get('summary', '')) return component.get('status') == 'TENTATIVE' or 'TBD' in summary or summary.endswith("?") -def process_calendar_events(calendar, start_date, end_date, is_tentative_fn=looks_tentative): +def make_clean_event(domain, component): + new_event = icalendar.cal.Event() + + new_event.add('SUMMARY', f"{config.name} Busy") + new_event.add('DTSTART', component['DTSTART']) + new_event.add('DTEND', component['DTEND']) + + # Ensure each event has a unique UID + new_event.add('UID', f'{uuid4()}@{domain}') + new_event.add('DTSTAMP', datetime.now(tz=utc)) + + return new_event + +def make_busy_period(component, dtstart, dtend, is_tentative_fn): + vp = icalendar.prop.vPeriod([t.astimezone(utc) for t in [dtstart, dtend]]) + vp.params.pop('TZID') # remove timezone information + + if is_tentative_fn(component): + vp.FBTYPE = icalendar.enums.FBTYPE.BUSY_TENTATIVE + else: + vp.FBTYPE = icalendar.enums.FBTYPE.BUSY_UNAVAILABLE + + return vp + +def process_calendar_events(calendar, start_date, end_date): """ - Process events from a calendar and return a list of event periods. + Process events from a calendar and return both event components and busy periods. This function examines calendar components, filters them by date range, determines if they are tentative based on the provided function, - and returns them as a list of vPeriod objects with FBTYPE property set. Args: calendar: The input calendar object to process start_date: Start date for filtering events end_date: End date for filtering events - is_tentative_fn: Optional function to determine if an event is tentative, - should accept a calendar component and return a boolean Returns: - List of vPeriod objects with FBTYPE set to either - BUSY_TENTATIVE or BUSY_UNAVAILABLE + Dictionary of vPeriod objects with FBTYPE set to BUSY_TENTATIVE or BUSY_UNAVAILABLE """ - events = [] + events = {} + for component in calendar.walk(): if component.name == "VEVENT": dtstart = fixup_date(component.get('dtstart').dt) dtend = fixup_date(component.get('dtend', component.get('dtstart')).dt) if dtstart >= start_date and dtend <= end_date: - vp = icalendar.prop.vPeriod([t.astimezone(utc) for t in [dtstart, dtend]]) - vp.params.pop('TZID') # remove timezone information + period_key = (dtstart, dtend) + events[period_key] = component - if is_tentative_fn(component): - vp.FBTYPE = icalendar.enums.FBTYPE.BUSY_TENTATIVE - else: - vp.FBTYPE = icalendar.enums.FBTYPE.BUSY_UNAVAILABLE + return events - events.append(vp) +def make_calendar(): + c = Calendar() + c.add('prodid', '-//Calendar Anonymiser//alin.ovh//') + c.add('version', '2.0') - return events + return c -@app.route(f'/{str.lower(config.name)}.ics') -async def index(): - today = date.today() - start_of_week = today - timedelta(days=today.weekday()) - start_date = datetime.combine(start_of_week, time.min, tzinfo=tz) - end_date = start_date + timedelta(days=30) - organizer = icalendar.prop.vCalAddress(f"mailto:{config.email}") - organizer.name = config.name - domain = config.email.split('@')[1] +async def update_calendar_cache(): + """ + Update the global calendar cache with fresh data + """ + try: + # Prepare data outside the lock - output = Calendar() - output.add('prodid', '-//Calendar Anonymiser//alin.ovh//') - output.add('version', '2.0') + domain = config.email.split('@')[1] - busy = FreeBusy(uid=f'{uuid4()}@{domain}') - busy.add('organizer', organizer) - busy.add('dtstamp', datetime.now(tz=utc)) - busy.add('dtstart', start_date.astimezone(tz=utc)) - busy.add('dtend', end_date.astimezone(tz=utc)) + today = date.today() + start_of_week = today - timedelta(days=today.weekday()) + start_date = datetime.combine(start_of_week, time.min, tzinfo=tz) + end_date = start_date + timedelta(days=30) - try: busy_periods = {} + events = {} for calendar_config in config.calendars: - calendar_data = get_calendar(calendar_config) - input_calendar = Calendar.from_ical(calendar_data) + try: + if 'all_tentative' in calendar_config and calendar_config['all_tentative']: + tentative_fn = lambda component: True + else: + tentative_fn = looks_tentative - if 'all_tentative' in calendar_config and calendar_config['all_tentative']: - tentative_fn = lambda component: True - else: - tentative_fn = looks_tentative + calendar_data = await get_calendar_async(calendar_config) + input_calendar = Calendar.from_ical(calendar_data) - events = process_calendar_events(input_calendar, start_date, end_date, is_tentative_fn=tentative_fn) + calendar_events = process_calendar_events(input_calendar, start_date, end_date) - # Process events considering tentative status - for event in events: - period_key = (event.dt[0], event.dt[1]) + for period_key, event_component in calendar_events.items(): + if not (period_key in events and tentative_fn(events[period_key])): + dtstart, dtend = period_key + events[period_key] = make_clean_event(domain, event_component) + busy_periods[period_key] = make_busy_period(event_component, dtstart, dtend, tentative_fn) - if period_key in busy_periods: - existing_event = busy_periods[period_key] + except Exception as e: + logging.error(f"Error processing calendar {calendar_config}: {str(e)}") + logging.error(traceback.format_exc()) + + # Generate the busy/free calendar + organizer = icalendar.prop.vCalAddress(f"mailto:{config.email}") + organizer.name = config.name + + busy_calendar = make_calendar() + + busy = FreeBusy(uid=f'{uuid4()}@{domain}') + busy.add('organizer', organizer) + busy.add('dtstamp', datetime.now(tz=utc)) + busy.add('dtstart', start_date.astimezone(tz=utc)) + busy.add('dtend', end_date.astimezone(tz=utc)) + + for period in busy_periods.values(): + busy.add('freebusy', period) + + busy_calendar.add_component(busy) + + events_calendar = make_calendar() + + for event_component in events.values(): + events_calendar.add_component(event_component) + + # Update the global cache + async with cache_condition: + calendar_cache['last_updated'] = datetime.now(tz=utc) + calendar_cache['busy_ical'] = busy_calendar.to_ical() + calendar_cache['events_ical'] = events_calendar.to_ical() + # Notify all waiting tasks that the update is complete + cache_condition.notify_all() + + logging.info(f"Calendar cache updated with {len(events)} events") + except Exception as e: + logging.error(f"Error updating calendar cache: {str(e)}") + logging.error(traceback.format_exc()) + # Notify waiters even on error + async with cache_condition: + cache_condition.notify_all() + +def seconds_until_next_day(): + """ + Calculate the number of seconds until midnight + """ + now = datetime.now(tz) + tomorrow = datetime.combine(now.date() + timedelta(days=1), time.min, tzinfo=tz) + return int((tomorrow - now).total_seconds()) + +async def periodic_calendar_update(): + """ + Background task to periodically update the calendar cache + """ + global next_update_seconds + while True: + await asyncio.sleep(1) + next_update_seconds -= 1 + if next_update_seconds <= 0: + await update_calendar_cache() + schedule_next_update() + +def schedule_next_update(): + global next_update_seconds + next_update_seconds = min(config.update_interval, seconds_until_next_day()) + +@app.before_serving +async def startup(): + """ + Startup tasks before the server begins serving requests + """ + global next_update_seconds + logging.basicConfig(level=logging.INFO) + await update_calendar_cache() + schedule_next_update() + app.add_background_task(periodic_calendar_update) - # If the existing event is tentative and the new one is not, - # replace the existing event with the non-tentative one - if (existing_event.FBTYPE == icalendar.enums.FBTYPE.BUSY_TENTATIVE and - event.FBTYPE == icalendar.enums.FBTYPE.BUSY_UNAVAILABLE): - busy_periods[period_key] = event - else: - busy_periods[period_key] = event +@app.route(f'/busy/{str.lower(config.name)}.ics') +@app.route(f'/{str.lower(config.name)}.ics') +async def busy_periods(): + try: + # Get the calendar data with a lock + async with cache_condition: + if calendar_cache['busy_ical']: + # Calculate age (time since last update) + age = 0 + if calendar_cache['last_updated']: + age = int((datetime.now(tz=utc) - calendar_cache['last_updated']).total_seconds()) - for event in busy_periods.values(): - busy.add('freebusy', event) + # Set headers with age and cache control + headers = { + "Content-Type": "text/calendar", + "Age": str(age), + "Cache-Control": f"max-age={next_update_seconds}", + "Last-Modified": calendar_cache['last_updated'].strftime("%a, %d %b %Y %H:%M:%S GMT") + } - output.add_component(busy) - return output.to_ical(), {"Content-Type": "text/calendar"} + return calendar_cache['busy_ical'], headers + else: + return "Calendar data not yet available. Please try again shortly.", 503 except Exception as e: - print(f"error: {str(e)} {''.join(traceback.format_exception(e))}") + logging.error(f"Error serving calendar: {str(e)}") + logging.error(traceback.format_exc()) return f"Error processing calendar: {str(e)}", 500 + +@app.route(f'/events/{str.lower(config.name)}.ics') +async def events(): + try: + # Get the calendar data with a lock + async with cache_condition: + if calendar_cache['events_ical']: + + # Calculate age (time since last update) + age = 0 + if calendar_cache['last_updated']: + age = int((datetime.now(tz=utc) - calendar_cache['last_updated']).total_seconds()) + + # Set headers with age and cache control + headers = { + "Content-Type": "text/calendar", + "Age": str(age), + "Cache-Control": f"max-age={next_update_seconds}", + "Last-Modified": calendar_cache['last_updated'].strftime("%a, %d %b %Y %H:%M:%S GMT") + } + + return calendar_cache['events_ical'], headers + else: + return "Calendar events data not yet available. Please try again shortly.", 503 + except Exception as e: + logging.error(f"Error serving events calendar: {str(e)}") + logging.error(traceback.format_exc()) + return f"Error processing calendar events: {str(e)}", 500 if __name__ == '__main__': app.run(debug=True)