From 77a94fa91b7cdd484d1981a3971e3d9f178c3843 Mon Sep 17 00:00:00 2001 From: Nikolay Tatarinov Date: Sat, 21 Feb 2026 23:41:00 +0300 Subject: [PATCH] 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. --- .coverage | Bin 53248 -> 53248 bytes duty_teller/api/app.py | 40 ++++++++++++++- duty_teller/api/personal_calendar_ics.py | 36 +++++++++++++ duty_teller/handlers/commands.py | 21 +++++--- duty_teller/i18n/messages.py | 17 +++++-- tests/test_app.py | 61 +++++++++++++++++++++++ tests/test_handlers_commands.py | 7 +-- tests/test_personal_calendar_ics.py | 18 ++++++- 8 files changed, 183 insertions(+), 17 deletions(-) diff --git a/.coverage b/.coverage index 4b5b5d94f75ce8599a3784865bfab362f730bb81..b3391c75bf44447a6515f7356ff540b6c054cb5d 100644 GIT binary patch delta 218 zcmV<0044u`paX!Q1F!~wL~8&K_z&<8(+|53nh%E$aSu@sLJu$w-ww_Wz7DevjShki zcMekyIu0@pCJq}80}cBPTq$PT>@m=28& zf(~8|MGidD1r*`+ccD|NZtq>fh7fcLM+b ze(%W#le3Q04h#ka0SOuvs(A$P-oF= 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", diff --git a/duty_teller/api/personal_calendar_ics.py b/duty_teller/api/personal_calendar_ics.py index 36248df..c0f2a29 100644 --- a/duty_teller/api/personal_calendar_ics.py +++ b/duty_teller/api/personal_calendar_ics.py @@ -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() diff --git a/duty_teller/handlers/commands.py b/duty_teller/handlers/commands.py index 9c16245..8cf2e58 100644 --- a/duty_teller/handlers/commands.py +++ b/duty_teller/handlers/commands.py @@ -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") ) diff --git a/duty_teller/i18n/messages.py b/duty_teller/i18n/messages.py index d1ac43f..bfab980 100644 --- a/duty_teller/i18n/messages.py +++ b/duty_teller/i18n/messages.py @@ -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)", diff --git a/tests/test_app.py b/tests/test_app.py index 86400f4..eb0a11b 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -268,6 +268,67 @@ def test_duties_200_with_unknown_event_type_mapped_to_duty(client): assert data[0]["full_name"] == "User A" +def test_calendar_ical_team_404_invalid_token_format(client): + """GET /api/calendar/ical/team/{token}.ics with invalid token format returns 404 without DB.""" + r = client.get("/api/calendar/ical/team/short.ics") + assert r.status_code == 404 + assert "not found" in r.text.lower() + + +@patch("duty_teller.api.app.get_user_by_calendar_token") +def test_calendar_ical_team_404_unknown_token(mock_get_user, client): + """GET /api/calendar/ical/team/{token}.ics with unknown token returns 404.""" + mock_get_user.return_value = None + valid_format_token = "B" * 43 + r = client.get(f"/api/calendar/ical/team/{valid_format_token}.ics") + assert r.status_code == 404 + assert "not found" in r.text.lower() + mock_get_user.assert_called_once() + + +@patch("duty_teller.api.app.build_team_ics") +@patch("duty_teller.api.app.get_duties") +@patch("duty_teller.api.app.get_user_by_calendar_token") +def test_calendar_ical_team_200_only_duty_and_description( + mock_get_user, mock_get_duties, mock_build_team_ics, client +): + """GET /api/calendar/ical/team/{token}.ics returns ICS with duty-only events and DESCRIPTION.""" + from types import SimpleNamespace + + mock_user = SimpleNamespace(id=1, full_name="User A") + mock_get_user.return_value = mock_user + duty = SimpleNamespace( + id=10, + user_id=1, + start_at="2026-06-15T09:00:00Z", + end_at="2026-06-15T18:00:00Z", + event_type="duty", + ) + non_duty = SimpleNamespace( + id=11, + user_id=2, + start_at="2026-06-16T09:00:00Z", + end_at="2026-06-16T18:00:00Z", + event_type="vacation", + ) + mock_get_duties.return_value = [(duty, "User A"), (non_duty, "User B")] + mock_build_team_ics.return_value = b"BEGIN:VCALENDAR\r\nPRODID:Team\r\nVEVENT\r\nDESCRIPTION:User A\r\nEND:VCALENDAR" + token = "y" * 43 + + r = client.get(f"/api/calendar/ical/team/{token}.ics") + assert r.status_code == 200 + assert r.headers.get("content-type", "").startswith("text/calendar") + assert b"BEGIN:VCALENDAR" in r.content + mock_get_user.assert_called_once() + mock_get_duties.assert_called_once() + # build_team_ics called with only duty (event_type duty), not vacation + mock_build_team_ics.assert_called_once() + duties_arg = mock_build_team_ics.call_args[0][0] + assert len(duties_arg) == 1 + assert duties_arg[0][0].event_type == "duty" + assert duties_arg[0][1] == "User A" + + def test_calendar_ical_404_invalid_token_format(client): """GET /api/calendar/ical/{token}.ics with invalid token format returns 404 without DB call.""" # Token format must be base64url, 40–50 chars; short or invalid chars → 404 diff --git a/tests/test_handlers_commands.py b/tests/test_handlers_commands.py index 66eb714..5e8c613 100644 --- a/tests/test_handlers_commands.py +++ b/tests/test_handlers_commands.py @@ -202,7 +202,7 @@ async def test_set_phone_result_error_replies_error(): @pytest.mark.asyncio async def test_calendar_link_with_user_and_token_replies_with_url(): - """calendar_link with allowed user and token -> reply with link.""" + """calendar_link with allowed user and token -> reply with personal and team URLs.""" message = MagicMock() message.reply_text = AsyncMock() user = _make_user() @@ -233,14 +233,15 @@ async def test_calendar_link_with_user_and_token_replies_with_url(): ): with patch("duty_teller.handlers.commands.t") as mock_t: mock_t.side_effect = lambda lang, key, **kw: ( - f"URL: {kw.get('url', '')}" + f"Personal: {kw.get('url_personal', '')} Team: {kw.get('url_team', '')}" if "success" in key else "Hint" ) await calendar_link(update, MagicMock()) message.reply_text.assert_called_once() call_args = message.reply_text.call_args[0][0] - assert "abc43token" in call_args or "example.com" in call_args + assert "/api/calendar/ical/abc43token.ics" in call_args + assert "/api/calendar/ical/team/abc43token.ics" in call_args @pytest.mark.asyncio diff --git a/tests/test_personal_calendar_ics.py b/tests/test_personal_calendar_ics.py index a2089f2..1ad717b 100644 --- a/tests/test_personal_calendar_ics.py +++ b/tests/test_personal_calendar_ics.py @@ -4,7 +4,7 @@ from types import SimpleNamespace from icalendar import Calendar as ICalendar -from duty_teller.api.personal_calendar_ics import build_personal_ics +from duty_teller.api.personal_calendar_ics import build_personal_ics, build_team_ics # Minimal Duty-like object for build_personal_ics(duties_with_name: list[tuple[Duty, str]]) @@ -45,6 +45,22 @@ def test_build_personal_ics_one_duty(): assert b"duty-1@duty-teller" in ics or "duty-1@duty-teller" in ics.decode("utf-8") +def test_build_team_ics_vevent_has_description_with_full_name(): + """Team ICS VEVENT includes DESCRIPTION with the duty holder's full_name.""" + duties = [ + _duty(1, "2025-02-20T09:00:00Z", "2025-02-20T18:00:00Z", "duty"), + ] + ics = build_team_ics(duties) + cal = ICalendar.from_ical(ics) + assert cal is not None + events = [c for c in cal.walk() if c.name == "VEVENT"] + assert len(events) == 1 + ev = events[0] + assert ev.get("summary") == "Duty" + assert ev.get("description") == "Test User" + assert b"Team Calendar" in ics or "Team Calendar" in ics.decode("utf-8") + + def test_build_personal_ics_event_types(): """Unavailable and vacation get correct SUMMARY.""" duties = [