feat: add calendar subscription token functionality and ICS generation
- Introduced a new database model for calendar subscription tokens, allowing users to generate unique tokens for accessing their personal calendar. - Implemented API endpoint to return ICS files containing only the subscribing user's duties, enhancing user experience with personalized calendar access. - Added utility functions for generating ICS files from user duties, ensuring proper formatting and timezone handling. - Updated command handlers to support the new calendar link feature, providing users with easy access to their personal calendar subscriptions. - Included unit tests for the new functionality, ensuring reliability and correctness of token generation and ICS file creation.
This commit is contained in:
57
duty_teller/api/personal_calendar_ics.py
Normal file
57
duty_teller/api/personal_calendar_ics.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Generate ICS calendar containing only one user's duties (for subscription link)."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from icalendar import Calendar, Event
|
||||
|
||||
from duty_teller.db.models import Duty
|
||||
|
||||
# Summary labels by event_type (duty | unavailable | vacation)
|
||||
SUMMARY_BY_TYPE: dict[str, str] = {
|
||||
"duty": "Duty",
|
||||
"unavailable": "Unavailable",
|
||||
"vacation": "Vacation",
|
||||
}
|
||||
|
||||
|
||||
def _parse_utc_iso(iso_str: str) -> datetime:
|
||||
"""Parse ISO 8601 UTC string (e.g. 2025-01-15T09:00:00Z) to timezone-aware datetime."""
|
||||
s = iso_str.strip().rstrip("Z")
|
||||
if "Z" in s:
|
||||
s = s.replace("Z", "+00:00")
|
||||
else:
|
||||
s = s + "+00:00"
|
||||
return datetime.fromisoformat(s)
|
||||
|
||||
|
||||
def build_personal_ics(duties_with_name: list[tuple[Duty, str]]) -> bytes:
|
||||
"""
|
||||
Build a single VCALENDAR with one VEVENT per duty.
|
||||
duties_with_name: list of (Duty, full_name); full_name is unused for SUMMARY
|
||||
if we use event_type only; can be used later for DESCRIPTION.
|
||||
"""
|
||||
cal = Calendar()
|
||||
cal.add("prodid", "-//Duty Teller//Personal Calendar//EN")
|
||||
cal.add("version", "2.0")
|
||||
cal.add("calscale", "GREGORIAN")
|
||||
|
||||
for duty, _full_name in duties_with_name:
|
||||
event = Event()
|
||||
start_dt = _parse_utc_iso(duty.start_at)
|
||||
end_dt = _parse_utc_iso(duty.end_at)
|
||||
# Ensure timezone-aware for icalendar
|
||||
if start_dt.tzinfo is None:
|
||||
start_dt = start_dt.replace(tzinfo=timezone.utc)
|
||||
if end_dt.tzinfo is None:
|
||||
end_dt = end_dt.replace(tzinfo=timezone.utc)
|
||||
event.add("dtstart", start_dt)
|
||||
event.add("dtend", end_dt)
|
||||
summary = SUMMARY_BY_TYPE.get(
|
||||
duty.event_type if duty.event_type else "duty", "Duty"
|
||||
)
|
||||
event.add("summary", summary)
|
||||
event.add("uid", f"duty-{duty.id}@duty-teller")
|
||||
event.add("dtstamp", datetime.now(timezone.utc))
|
||||
cal.add_component(event)
|
||||
|
||||
return cal.to_ical()
|
||||
Reference in New Issue
Block a user