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:
2026-02-19 17:04:22 +03:00
parent 4afd0ca5cc
commit dc116270b7
14 changed files with 501 additions and 12 deletions

View File

@@ -1,10 +1,12 @@
"""FastAPI app: /api/duties and static webapp."""
import logging
from datetime import date, timedelta
import duty_teller.config as config
from fastapi import Depends, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import Response
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session
@@ -15,6 +17,8 @@ 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.db.schemas import CalendarEvent, DutyWithUser
log = logging.getLogger(__name__)
@@ -57,6 +61,31 @@ def list_calendar_events(
return [CalendarEvent(date=e["date"], summary=e["summary"]) for e in events]
@app.get("/api/calendar/ical/{token}.ics")
def get_personal_calendar_ical(
token: str,
session: Session = Depends(get_db_session),
) -> Response:
"""
Return ICS calendar with only the subscribing user's duties.
No Telegram auth; access is by secret token in the URL.
"""
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")
duties_with_name = get_duties_for_user(
session, user.id, from_date=from_date, to_date=to_date
)
ics_bytes = build_personal_ics(duties_with_name)
return Response(
content=ics_bytes,
media_type="text/calendar; charset=utf-8",
)
webapp_path = config.PROJECT_ROOT / "webapp"
if webapp_path.is_dir():
app.mount("/app", StaticFiles(directory=str(webapp_path), html=True), name="webapp")

View 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()