feat: add team calendar ICS endpoint and related functionality
All checks were successful
CI / lint-and-test (push) Successful in 23s
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:
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -122,21 +122,30 @@ async def calendar_link(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
|
||||
if not can_access_miniapp_for_telegram_user(session, telegram_user_id):
|
||||
return (None, "denied")
|
||||
token = create_calendar_token(session, user.id)
|
||||
base = (config.MINI_APP_BASE_URL or "").rstrip("/")
|
||||
url = f"{base}/api/calendar/ical/{token}.ics" if base else None
|
||||
return (url, None)
|
||||
return (token, None)
|
||||
|
||||
result_url, error = await asyncio.get_running_loop().run_in_executor(
|
||||
result_token, error = await asyncio.get_running_loop().run_in_executor(
|
||||
None, do_calendar_link
|
||||
)
|
||||
if error == "denied":
|
||||
await update.message.reply_text(t(lang, "calendar_link.access_denied"))
|
||||
return
|
||||
if not result_url:
|
||||
if not result_token:
|
||||
await update.message.reply_text(t(lang, "calendar_link.error"))
|
||||
return
|
||||
base = (config.MINI_APP_BASE_URL or "").rstrip("/")
|
||||
if not base:
|
||||
await update.message.reply_text(t(lang, "calendar_link.error"))
|
||||
return
|
||||
url_personal = f"{base}/api/calendar/ical/{result_token}.ics"
|
||||
url_team = f"{base}/api/calendar/ical/team/{result_token}.ics"
|
||||
await update.message.reply_text(
|
||||
t(lang, "calendar_link.success", url=result_url)
|
||||
t(
|
||||
lang,
|
||||
"calendar_link.success",
|
||||
url_personal=url_personal,
|
||||
url_team=url_team,
|
||||
)
|
||||
+ "\n\n"
|
||||
+ t(lang, "calendar_link.help_hint")
|
||||
)
|
||||
|
||||
@@ -15,10 +15,12 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"help.pin_duty": "/pin_duty — In a group: pin the duty message (bot needs admin with Pin messages)",
|
||||
"calendar_link.private_only": "The /calendar_link command is only available in private chat.",
|
||||
"calendar_link.access_denied": "Access denied.",
|
||||
"calendar_link.success": "Your personal calendar URL:\n{url}",
|
||||
"calendar_link.success": (
|
||||
"Personal (your duties only):\n{url_personal}\n\n"
|
||||
"Team (all duties):\n{url_team}"
|
||||
),
|
||||
"calendar_link.help_hint": (
|
||||
"Subscribe to this URL in Google Calendar, Apple Calendar, or Outlook to "
|
||||
"see only your duties."
|
||||
"Subscribe to these URLs in Google Calendar, Apple Calendar, or Outlook."
|
||||
),
|
||||
"calendar_link.error": "Could not generate link. Please try again later.",
|
||||
"help.import_schedule": "/import_duty_schedule — Import duty schedule (JSON)",
|
||||
@@ -82,8 +84,13 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"help.pin_duty": "/pin_duty — В группе: закрепить сообщение о дежурстве (нужны права админа у бота)",
|
||||
"calendar_link.private_only": "Команда /calendar_link доступна только в личке.",
|
||||
"calendar_link.access_denied": "Доступ запрещён.",
|
||||
"calendar_link.success": "Ссылка на ваш календарь:\n{url}",
|
||||
"calendar_link.help_hint": "Подпишитесь на эту ссылку в Google Календаре, Календаре Apple или Outlook, чтобы видеть только свои дежурства.",
|
||||
"calendar_link.success": (
|
||||
"Персональный (только ваши дежурства):\n{url_personal}\n\n"
|
||||
"Общий (все дежурства):\n{url_team}"
|
||||
),
|
||||
"calendar_link.help_hint": (
|
||||
"Подпишитесь на эти ссылки в Google Календаре, Календаре Apple или Outlook."
|
||||
),
|
||||
"calendar_link.error": "Не удалось сформировать ссылку. Попробуйте позже.",
|
||||
"help.import_schedule": "/import_duty_schedule — Импорт расписания дежурств (JSON)",
|
||||
"help.set_role": "/set_role — Выдать роль пользователю (user | admin)",
|
||||
|
||||
Reference in New Issue
Block a user