feat: add team calendar ICS endpoint and related functionality
All checks were successful
CI / lint-and-test (push) Successful in 23s

- 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.
This commit is contained in:
2026-02-21 23:41:00 +03:00
parent 0c93fe3372
commit 77a94fa91b
8 changed files with 183 additions and 17 deletions

View File

@@ -17,8 +17,12 @@ from duty_teller.api.dependencies import (
get_validated_dates,
require_miniapp_username,
)
from duty_teller.api.personal_calendar_ics import build_personal_ics
from duty_teller.db.repository import get_duties_for_user, get_user_by_calendar_token
from duty_teller.api.personal_calendar_ics import build_personal_ics, build_team_ics
from duty_teller.db.repository import (
get_duties,
get_duties_for_user,
get_user_by_calendar_token,
)
from duty_teller.db.schemas import CalendarEvent, DutyWithUser
log = logging.getLogger(__name__)
@@ -94,6 +98,38 @@ def list_calendar_events(
return [CalendarEvent(date=e["date"], summary=e["summary"]) for e in events]
@app.get(
"/api/calendar/ical/team/{token}.ics",
summary="Team calendar ICS",
description=(
"Returns an ICS calendar with all team duty shifts. Each event has "
"DESCRIPTION set to the duty holder's name. No Telegram auth; access by token."
),
)
def get_team_calendar_ical(
token: str,
session: Session = Depends(get_db_session),
) -> Response:
"""Return ICS calendar with all duties (event_type duty only). Token validates user."""
if not _is_valid_calendar_token(token):
return Response(status_code=404, content="Not found")
user = get_user_by_calendar_token(session, token)
if user is None:
return Response(status_code=404, content="Not found")
today = date.today()
from_date = (today - timedelta(days=365)).strftime("%Y-%m-%d")
to_date = (today + timedelta(days=365 * 2)).strftime("%Y-%m-%d")
all_duties = get_duties(session, from_date=from_date, to_date=to_date)
duties_duty_only = [
(d, name) for d, name in all_duties if (d.event_type or "duty") == "duty"
]
ics_bytes = build_team_ics(duties_duty_only)
return Response(
content=ics_bytes,
media_type="text/calendar; charset=utf-8",
)
@app.get(
"/api/calendar/ical/{token}.ics",
summary="Personal calendar ICS",

View File

@@ -50,3 +50,39 @@ def build_personal_ics(duties_with_name: list[tuple[Duty, str]]) -> bytes:
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()