Files
duty-teller/duty_teller/api/personal_calendar_ics.py
Nikolay Tatarinov 77a94fa91b
All checks were successful
CI / lint-and-test (push) Successful in 23s
feat: add team calendar ICS endpoint and related functionality
- Implemented a new API endpoint to generate an ICS calendar for team duty shifts, accessible via a valid token.
- Enhanced the `calendar_link` command to return both personal and team calendar URLs.
- Added a new function to build the team ICS file, ensuring each event includes the duty holder's name in the description.
- Updated tests to cover the new team calendar functionality, including validation for token formats and response content.
- Revised internationalization messages to reflect the new team calendar links.
2026-02-21 23:41:00 +03:00

89 lines
3.0 KiB
Python

"""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
from duty_teller.utils.dates import parse_utc_iso
# Summary labels by event_type (duty | unavailable | vacation)
SUMMARY_BY_TYPE: dict[str, str] = {
"duty": "Duty",
"unavailable": "Unavailable",
"vacation": "Vacation",
}
def build_personal_ics(duties_with_name: list[tuple[Duty, str]]) -> bytes:
"""Build a VCALENDAR (ICS) with one VEVENT per duty.
Args:
duties_with_name: List of (Duty, full_name). full_name is available for
DESCRIPTION; SUMMARY is taken from event_type (duty/unavailable/vacation).
Returns:
ICS file content as bytes (UTF-8).
"""
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()
def build_team_ics(duties_with_name: list[tuple[Duty, str]]) -> bytes:
"""Build a VCALENDAR (ICS) with one VEVENT per duty for team calendar.
Same structure as personal calendar; PRODID is Team Calendar; each VEVENT
has SUMMARY='Duty' and DESCRIPTION set to the duty holder's full_name.
Args:
duties_with_name: List of (Duty, full_name). full_name is used for DESCRIPTION.
Returns:
ICS file content as bytes (UTF-8).
"""
cal = Calendar()
cal.add("prodid", "-//Duty Teller//Team 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)
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)
event.add("summary", "Duty")
event.add("description", full_name)
event.add("uid", f"duty-{duty.id}@duty-teller")
event.add("dtstamp", datetime.now(timezone.utc))
cal.add_component(event)
return cal.to_ical()