forked from andika/membership-be
128 lines
4.0 KiB
Python
128 lines
4.0 KiB
Python
"""
|
|
Calendar Service for generating iCalendar (.ics) data
|
|
Implements RFC 5545 iCalendar format for universal calendar compatibility
|
|
"""
|
|
|
|
from icalendar import Calendar, Event as iCalEvent, Alarm
|
|
from datetime import datetime, timedelta
|
|
from zoneinfo import ZoneInfo
|
|
import uuid
|
|
import os
|
|
|
|
|
|
class CalendarService:
|
|
"""Service for generating iCalendar (.ics) data compatible with all calendar apps"""
|
|
|
|
def __init__(self):
|
|
self.domain = os.getenv('CALENDAR_DOMAIN', 'loaf.community')
|
|
self.timezone = ZoneInfo(os.getenv('CALENDAR_TIMEZONE', 'America/New_York'))
|
|
|
|
def generate_event_uid(self) -> str:
|
|
"""
|
|
Generate unique event identifier (UUID4 hex-encoded per RFC 7986)
|
|
|
|
Returns:
|
|
str: Unique identifier in format {uuid}@{domain}
|
|
"""
|
|
return f"{uuid.uuid4().hex}@{self.domain}"
|
|
|
|
def event_to_ical_event(self, event, include_reminder: bool = True):
|
|
"""
|
|
Convert database Event model to iCalendar Event component
|
|
|
|
Args:
|
|
event: Event model instance from database
|
|
include_reminder: Whether to add 1-hour reminder alarm
|
|
|
|
Returns:
|
|
icalendar.Event: iCalendar event component
|
|
"""
|
|
ical_event = iCalEvent()
|
|
|
|
# Required properties
|
|
ical_event.add('uid', event.calendar_uid or self.generate_event_uid())
|
|
ical_event.add('dtstamp', datetime.now(self.timezone))
|
|
ical_event.add('dtstart', event.start_at)
|
|
ical_event.add('dtend', event.end_at)
|
|
ical_event.add('summary', event.title)
|
|
|
|
# Optional properties
|
|
if event.description:
|
|
ical_event.add('description', event.description)
|
|
if event.location:
|
|
ical_event.add('location', event.location)
|
|
|
|
# Metadata
|
|
ical_event.add('url', f"https://{self.domain}/events/{event.id}")
|
|
ical_event.add('status', 'CONFIRMED')
|
|
ical_event.add('sequence', 0)
|
|
|
|
# Add 1-hour reminder (VALARM component)
|
|
if include_reminder:
|
|
alarm = Alarm()
|
|
alarm.add('action', 'DISPLAY')
|
|
alarm.add('description', f"Reminder: {event.title}")
|
|
alarm.add('trigger', timedelta(hours=-1))
|
|
ical_event.add_component(alarm)
|
|
|
|
return ical_event
|
|
|
|
def create_calendar(self, name: str, description: str = None):
|
|
"""
|
|
Create base calendar with metadata
|
|
|
|
Args:
|
|
name: Calendar name (X-WR-CALNAME)
|
|
description: Optional calendar description
|
|
|
|
Returns:
|
|
icalendar.Calendar: Base calendar object
|
|
"""
|
|
cal = Calendar()
|
|
cal.add('prodid', '-//LOAF Membership Platform//EN')
|
|
cal.add('version', '2.0')
|
|
cal.add('x-wr-calname', name)
|
|
cal.add('x-wr-timezone', str(self.timezone))
|
|
if description:
|
|
cal.add('x-wr-caldesc', description)
|
|
cal.add('method', 'PUBLISH')
|
|
cal.add('calscale', 'GREGORIAN')
|
|
return cal
|
|
|
|
def create_single_event_calendar(self, event) -> bytes:
|
|
"""
|
|
Create calendar with single event for download
|
|
|
|
Args:
|
|
event: Event model instance
|
|
|
|
Returns:
|
|
bytes: iCalendar data as bytes
|
|
"""
|
|
cal = self.create_calendar(event.title)
|
|
ical_event = self.event_to_ical_event(event)
|
|
cal.add_component(ical_event)
|
|
return cal.to_ics()
|
|
|
|
def create_subscription_feed(self, events: list, feed_name: str) -> bytes:
|
|
"""
|
|
Create calendar subscription feed with multiple events
|
|
|
|
Args:
|
|
events: List of Event model instances
|
|
feed_name: Name for the calendar feed
|
|
|
|
Returns:
|
|
bytes: iCalendar data as bytes
|
|
"""
|
|
cal = self.create_calendar(
|
|
feed_name,
|
|
description="LOAF Community Events - Auto-syncing calendar feed"
|
|
)
|
|
|
|
for event in events:
|
|
ical_event = self.event_to_ical_event(event)
|
|
cal.add_component(ical_event)
|
|
|
|
return cal.to_ics()
|