diff --git a/.coverage b/.coverage index b3391c7..5d4fa23 100644 Binary files a/.coverage and b/.coverage differ diff --git a/.env.example b/.env.example index 97da450..5d6a6ab 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,9 @@ ADMIN_USERNAMES=admin1,admin2 # Timezone for the pinned duty message in groups (e.g. Europe/Moscow). # DUTY_DISPLAY_TZ=Europe/Moscow +# When the pinned duty message is updated on schedule, re-pin so members get a notification (default: 1). Set to 0 or false to disable. +# DUTY_PIN_NOTIFY=1 + # Default UI language when user language is unknown: en or ru (default: en). # DEFAULT_LANGUAGE=en diff --git a/CHANGELOG.md b/CHANGELOG.md index a1f48ff..cc203d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Group duty pin**: when the pinned duty message is updated on schedule, the bot re-pins it so group members get a Telegram notification. Configurable via `DUTY_PIN_NOTIFY` (default: enabled); set to `0` or `false` to only edit the message without re-pinning. + ## [0.1.0] - 2025-02-20 ### Added diff --git a/docs/architecture.md b/docs/architecture.md index 80c2216..d37edc9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,7 +8,7 @@ High-level architecture of Duty Teller: components, data flow, and package relat - **FastAPI** — HTTP server: REST API (`/api/duties`, `/api/calendar-events`, `/api/calendar/ical/{token}.ics`) and static miniapp at `/app`. Runs in a separate thread alongside the bot. - **Database** — SQLAlchemy ORM with Alembic migrations. Default backend: SQLite (`data/duty_teller.db`). Stores users, duties (with event types: duty, unavailable, vacation), group duty pins, calendar subscription tokens. - **Duty-schedule import** — Two-step admin flow: handover time (timezone → UTC), then JSON file. Parser produces per-person date lists; import service deletes existing duties in range and inserts new ones. -- **Group duty pin** — In groups, the bot can pin the current duty message; time/timezone for the pinned text come from `DUTY_DISPLAY_TZ`. Pin state is restored on startup from the database. +- **Group duty pin** — In groups, the bot can pin the current duty message; time/timezone for the pinned text come from `DUTY_DISPLAY_TZ`. Pin state is restored on startup from the database. When the duty changes on schedule, the bot edits the pinned message and, if `DUTY_PIN_NOTIFY` is enabled (default), re-pins it so that members get a Telegram notification; the first pin (bot added to group or `/pin_duty`) is always silent. ## Data flow diff --git a/docs/configuration.md b/docs/configuration.md index 7940dab..1eaed10 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -17,6 +17,7 @@ All configuration is read from the environment (e.g. `.env` via python-dotenv). | **CORS_ORIGINS** | comma-separated list | `*` | Allowed origins for CORS. Leave unset or set to `*` for allow-all. Example: `https://your-domain.com`. | | **EXTERNAL_CALENDAR_ICS_URL** | string (URL) | *(empty)* | URL of a public ICS calendar (e.g. holidays). If set, those days are highlighted on the duty grid; users can tap "i" on a cell to see the event summary. Empty = no external calendar. | | **DUTY_DISPLAY_TZ** | string (timezone name) | `Europe/Moscow` | Timezone for the pinned duty message in groups. Example: `Europe/Moscow`, `UTC`. | +| **DUTY_PIN_NOTIFY** | `0`, `false`, or `no` to disable | `1` (enabled) | When the pinned duty message is updated on schedule, the bot re-pins it so that group members get a Telegram notification (“Bot pinned a message”). Set to `0`, `false`, or `no` to only edit the message without re-pinning (no notification). The first pin (e.g. when the bot is added to the group or on `/pin_duty`) is always silent. | | **DEFAULT_LANGUAGE** | `en` or `ru` (normalized) | `en` | Default UI language when the user's Telegram language is unknown. Values starting with `ru` are normalized to `ru`, otherwise `en`. | ## Roles and access diff --git a/duty_teller/api/personal_calendar_ics.py b/duty_teller/api/personal_calendar_ics.py index c0f2a29..e7858ea 100644 --- a/duty_teller/api/personal_calendar_ics.py +++ b/duty_teller/api/personal_calendar_ics.py @@ -56,10 +56,12 @@ 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. + has SUMMARY set to the duty holder's full_name (or "Duty" if full_name is + empty or missing), and DESCRIPTION set to full_name. Args: - duties_with_name: List of (Duty, full_name). full_name is used for DESCRIPTION. + duties_with_name: List of (Duty, full_name). full_name is used for + SUMMARY and DESCRIPTION. Returns: ICS file content as bytes (UTF-8). @@ -79,7 +81,8 @@ def build_team_ics(duties_with_name: list[tuple[Duty, str]]) -> bytes: end_dt = end_dt.replace(tzinfo=timezone.utc) event.add("dtstart", start_dt) event.add("dtend", end_dt) - event.add("summary", "Duty") + summary = (full_name or "").strip() or "Duty" + event.add("summary", summary) event.add("description", full_name) event.add("uid", f"duty-{duty.id}@duty-teller") event.add("dtstamp", datetime.now(timezone.utc)) diff --git a/duty_teller/config.py b/duty_teller/config.py index 144ce82..5281a3a 100644 --- a/duty_teller/config.py +++ b/duty_teller/config.py @@ -64,6 +64,7 @@ class Settings: external_calendar_ics_url: str duty_display_tz: str default_language: str + duty_pin_notify: bool @classmethod def from_env(cls) -> "Settings": @@ -108,6 +109,8 @@ class Settings: duty_display_tz=os.getenv("DUTY_DISPLAY_TZ", "Europe/Moscow").strip() or "Europe/Moscow", default_language=normalize_lang(os.getenv("DEFAULT_LANGUAGE", "en")), + duty_pin_notify=os.getenv("DUTY_PIN_NOTIFY", "1").strip().lower() + not in ("0", "false", "no"), ) @@ -128,6 +131,7 @@ CORS_ORIGINS = _settings.cors_origins EXTERNAL_CALENDAR_ICS_URL = _settings.external_calendar_ics_url DUTY_DISPLAY_TZ = _settings.duty_display_tz DEFAULT_LANGUAGE = _settings.default_language +DUTY_PIN_NOTIFY = _settings.duty_pin_notify def is_admin(username: str) -> bool: diff --git a/duty_teller/handlers/__init__.py b/duty_teller/handlers/__init__.py index 6f416de..7a12728 100644 --- a/duty_teller/handlers/__init__.py +++ b/duty_teller/handlers/__init__.py @@ -21,4 +21,5 @@ def register_handlers(app: Application) -> None: app.add_handler(import_duty_schedule.duty_schedule_document_handler) app.add_handler(group_duty_pin.group_duty_pin_handler) app.add_handler(group_duty_pin.pin_duty_handler) + app.add_handler(group_duty_pin.refresh_pin_handler) app.add_error_handler(errors.error_handler) diff --git a/duty_teller/handlers/commands.py b/duty_teller/handlers/commands.py index 8cf2e58..335d8fa 100644 --- a/duty_teller/handlers/commands.py +++ b/duty_teller/handlers/commands.py @@ -163,6 +163,7 @@ async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: t(lang, "help.set_phone"), t(lang, "help.calendar_link"), t(lang, "help.pin_duty"), + t(lang, "help.refresh_pin"), ] if await is_admin_async(update.effective_user.id): lines.append(t(lang, "help.import_schedule")) diff --git a/duty_teller/handlers/group_duty_pin.py b/duty_teller/handlers/group_duty_pin.py index 275cfff..29cca17 100644 --- a/duty_teller/handlers/group_duty_pin.py +++ b/duty_teller/handlers/group_duty_pin.py @@ -3,6 +3,7 @@ import asyncio import logging from datetime import datetime, timezone +from typing import Literal import duty_teller.config as config from telegram import Update @@ -90,16 +91,21 @@ async def _schedule_next_update( ) -async def update_group_pin(context: ContextTypes.DEFAULT_TYPE) -> None: - """Job callback: refresh pinned duty message and schedule next update at shift end.""" - chat_id = context.job.data.get("chat_id") - if chat_id is None: - return +async def _refresh_pin_for_chat( + context: ContextTypes.DEFAULT_TYPE, chat_id: int +) -> Literal["updated", "no_message", "failed"]: + """Refresh pinned duty message for a chat: edit text, optionally re-pin, schedule next. + + Returns: + "updated" if the message was updated successfully; + "no_message" if there is no pin record for this chat; + "failed" if edit or permissions failed. + """ loop = asyncio.get_running_loop() message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id) if message_id is None: logger.info("No pin record for chat_id=%s, skipping update", chat_id) - return + return "no_message" text = await loop.run_in_executor( None, lambda: _get_duty_message_text_sync(config.DEFAULT_LANGUAGE) ) @@ -111,8 +117,32 @@ async def update_group_pin(context: ContextTypes.DEFAULT_TYPE) -> None: ) except (BadRequest, Forbidden) as e: logger.warning("Failed to edit pinned message chat_id=%s: %s", chat_id, e) + next_end = await loop.run_in_executor(None, _get_next_shift_end_sync) + await _schedule_next_update(context.application, chat_id, next_end) + return "failed" + if config.DUTY_PIN_NOTIFY: + try: + await context.bot.unpin_chat_message(chat_id=chat_id) + await context.bot.pin_chat_message( + chat_id=chat_id, + message_id=message_id, + disable_notification=False, + ) + except (BadRequest, Forbidden) as e: + logger.warning( + "Re-pin after update failed chat_id=%s: %s", chat_id, e + ) next_end = await loop.run_in_executor(None, _get_next_shift_end_sync) await _schedule_next_update(context.application, chat_id, next_end) + return "updated" + + +async def update_group_pin(context: ContextTypes.DEFAULT_TYPE) -> None: + """Job callback: refresh pinned duty message and schedule next update at shift end.""" + chat_id = context.job.data.get("chat_id") + if chat_id is None: + return + await _refresh_pin_for_chat(context, chat_id) async def my_chat_member_handler( @@ -223,8 +253,25 @@ async def pin_duty_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No await update.message.reply_text(t(lang, "pin_duty.failed")) +async def refresh_pin_cmd( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """Handle /refresh_pin: immediately refresh pinned duty message in the group.""" + if not update.message or not update.effective_chat or not update.effective_user: + return + chat = update.effective_chat + lang = get_lang(update.effective_user) + if chat.type not in ("group", "supergroup"): + await update.message.reply_text(t(lang, "refresh_pin.group_only")) + return + chat_id = chat.id + result = await _refresh_pin_for_chat(context, chat_id) + await update.message.reply_text(t(lang, f"refresh_pin.{result}")) + + group_duty_pin_handler = ChatMemberHandler( my_chat_member_handler, ChatMemberHandler.MY_CHAT_MEMBER, ) pin_duty_handler = CommandHandler("pin_duty", pin_duty_cmd) +refresh_pin_handler = CommandHandler("refresh_pin", refresh_pin_cmd) diff --git a/duty_teller/i18n/messages.py b/duty_teller/i18n/messages.py index bfab980..bee5409 100644 --- a/duty_teller/i18n/messages.py +++ b/duty_teller/i18n/messages.py @@ -13,6 +13,11 @@ MESSAGES: dict[str, dict[str, str]] = { "help.set_phone": "/set_phone — Set or clear phone for duty display", "help.calendar_link": "/calendar_link — Get personal calendar subscription link (private chat)", "help.pin_duty": "/pin_duty — In a group: pin the duty message (bot needs admin with Pin messages)", + "help.refresh_pin": "/refresh_pin — In a group: refresh pinned duty message text immediately", + "refresh_pin.group_only": "The /refresh_pin command works only in groups.", + "refresh_pin.no_message": "There is no pinned duty message to refresh in this chat.", + "refresh_pin.updated": "Pinned duty message updated.", + "refresh_pin.failed": "Could not update the pinned message (permissions or edit error).", "calendar_link.private_only": "The /calendar_link command is only available in private chat.", "calendar_link.access_denied": "Access denied.", "calendar_link.success": ( @@ -82,6 +87,11 @@ MESSAGES: dict[str, dict[str, str]] = { "help.set_phone": "/set_phone — Указать или очистить телефон для отображения в дежурстве", "help.calendar_link": "/calendar_link — Получить ссылку на персональную подписку календаря (только в личке)", "help.pin_duty": "/pin_duty — В группе: закрепить сообщение о дежурстве (нужны права админа у бота)", + "help.refresh_pin": "/refresh_pin — В группе: немедленно обновить текст закреплённого сообщения о дежурстве", + "refresh_pin.group_only": "Команда /refresh_pin работает только в группах.", + "refresh_pin.no_message": "В этом чате нет закреплённого сообщения о дежурстве для обновления.", + "refresh_pin.updated": "Закреплённое сообщение о дежурстве обновлено.", + "refresh_pin.failed": "Не удалось обновить закреплённое сообщение (права или ошибка редактирования).", "calendar_link.private_only": "Команда /calendar_link доступна только в личке.", "calendar_link.access_denied": "Доступ запрещён.", "calendar_link.success": ( diff --git a/tests/test_handlers_group_duty_pin.py b/tests/test_handlers_group_duty_pin.py index 466135e..ccd1041 100644 --- a/tests/test_handlers_group_duty_pin.py +++ b/tests/test_handlers_group_duty_pin.py @@ -4,9 +4,10 @@ from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, MagicMock, patch import pytest -from telegram.error import BadRequest +from telegram.error import BadRequest, Forbidden from telegram.constants import ChatMemberStatus +import duty_teller.config as config from duty_teller.handlers import group_duty_pin as mod @@ -126,27 +127,34 @@ async def test_schedule_next_update_when_utc_none_runs_once_with_retry_delay(): @pytest.mark.asyncio async def test_update_group_pin_edits_message_and_schedules_next(): - """update_group_pin: with message_id and text, edits message and schedules next update.""" + """update_group_pin: with message_id and text, edits message, re-pins with notification, schedules next.""" context = MagicMock() context.job = MagicMock() context.job.data = {"chat_id": 123} context.bot = MagicMock() context.bot.edit_message_text = AsyncMock() + context.bot.unpin_chat_message = AsyncMock() + context.bot.pin_chat_message = AsyncMock() context.application = MagicMock() context.application.job_queue = MagicMock() context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[]) context.application.job_queue.run_once = MagicMock() - with patch.object(mod, "_sync_get_message_id", return_value=1): - with patch.object( - mod, "_get_duty_message_text_sync", return_value="Current duty" - ): - with patch.object(mod, "_get_next_shift_end_sync", return_value=None): - with patch.object(mod, "_schedule_next_update", AsyncMock()): - await mod.update_group_pin(context) + with patch.object(config, "DUTY_PIN_NOTIFY", True): + with patch.object(mod, "_sync_get_message_id", return_value=1): + with patch.object( + mod, "_get_duty_message_text_sync", return_value="Current duty" + ): + with patch.object(mod, "_get_next_shift_end_sync", return_value=None): + with patch.object(mod, "_schedule_next_update", AsyncMock()): + await mod.update_group_pin(context) context.bot.edit_message_text.assert_called_once_with( chat_id=123, message_id=1, text="Current duty" ) + context.bot.unpin_chat_message.assert_called_once_with(chat_id=123) + context.bot.pin_chat_message.assert_called_once_with( + chat_id=123, message_id=1, disable_notification=False + ) @pytest.mark.asyncio @@ -165,7 +173,7 @@ async def test_update_group_pin_no_message_id_skips(): @pytest.mark.asyncio async def test_update_group_pin_edit_raises_bad_request_still_schedules_next(): - """update_group_pin: edit_message_text BadRequest -> log, _schedule_next_update still called.""" + """update_group_pin: edit_message_text BadRequest -> no unpin/pin, _schedule_next_update still called.""" context = MagicMock() context.job = MagicMock() context.job.data = {"chat_id": 111} @@ -173,6 +181,8 @@ async def test_update_group_pin_edit_raises_bad_request_still_schedules_next(): context.bot.edit_message_text = AsyncMock( side_effect=BadRequest("Message not modified") ) + context.bot.unpin_chat_message = AsyncMock() + context.bot.pin_chat_message = AsyncMock() context.application = MagicMock() with patch.object(mod, "_sync_get_message_id", return_value=2): @@ -182,9 +192,62 @@ async def test_update_group_pin_edit_raises_bad_request_still_schedules_next(): mod, "_schedule_next_update", AsyncMock() ) as mock_schedule: await mod.update_group_pin(context) + context.bot.unpin_chat_message.assert_not_called() + context.bot.pin_chat_message.assert_not_called() mock_schedule.assert_called_once_with(context.application, 111, None) +@pytest.mark.asyncio +async def test_update_group_pin_repin_raises_still_schedules_next(): + """update_group_pin: edit succeeds, unpin or pin raises -> log, _schedule_next_update still called.""" + context = MagicMock() + context.job = MagicMock() + context.job.data = {"chat_id": 222} + context.bot = MagicMock() + context.bot.edit_message_text = AsyncMock() + context.bot.unpin_chat_message = AsyncMock(side_effect=Forbidden("Not enough rights")) + context.bot.pin_chat_message = AsyncMock() + context.application = MagicMock() + + with patch.object(config, "DUTY_PIN_NOTIFY", True): + with patch.object(mod, "_sync_get_message_id", return_value=3): + with patch.object(mod, "_get_duty_message_text_sync", return_value="Text"): + with patch.object(mod, "_get_next_shift_end_sync", return_value=None): + with patch.object( + mod, "_schedule_next_update", AsyncMock() + ) as mock_schedule: + with patch.object(mod, "logger") as mock_logger: + await mod.update_group_pin(context) + context.bot.edit_message_text.assert_called_once() + mock_logger.warning.assert_called_once() + assert "Re-pin" in mock_logger.warning.call_args[0][0] + mock_schedule.assert_called_once_with(context.application, 222, None) + + +@pytest.mark.asyncio +async def test_update_group_pin_duty_pin_notify_false_skips_repin(): + """update_group_pin: DUTY_PIN_NOTIFY False -> edit only, no unpin/pin, schedule next.""" + context = MagicMock() + context.job = MagicMock() + context.job.data = {"chat_id": 333} + context.bot = MagicMock() + context.bot.edit_message_text = AsyncMock() + context.bot.unpin_chat_message = AsyncMock() + context.bot.pin_chat_message = AsyncMock() + context.application = MagicMock() + + with patch.object(config, "DUTY_PIN_NOTIFY", False): + with patch.object(mod, "_sync_get_message_id", return_value=4): + with patch.object(mod, "_get_duty_message_text_sync", return_value="Text"): + with patch.object(mod, "_get_next_shift_end_sync", return_value=None): + with patch.object(mod, "_schedule_next_update", AsyncMock()) as mock_schedule: + await mod.update_group_pin(context) + context.bot.edit_message_text.assert_called_once() + context.bot.unpin_chat_message.assert_not_called() + context.bot.pin_chat_message.assert_not_called() + mock_schedule.assert_called_once_with(context.application, 333, None) + + @pytest.mark.asyncio async def test_pin_duty_cmd_group_only_reply(): """pin_duty_cmd in private chat -> reply group_only.""" @@ -276,6 +339,98 @@ async def test_pin_duty_cmd_pin_raises_replies_failed(): mock_t.assert_called_with("en", "pin_duty.failed") +# --- refresh_pin_cmd --- + + +@pytest.mark.asyncio +async def test_refresh_pin_cmd_private_replies_group_only(): + """refresh_pin_cmd in private chat -> reply refresh_pin.group_only.""" + update = MagicMock() + update.message = MagicMock() + update.message.reply_text = AsyncMock() + update.effective_chat = MagicMock() + update.effective_chat.type = "private" + update.effective_chat.id = 1 + update.effective_user = MagicMock() + context = MagicMock() + + with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"): + with patch("duty_teller.handlers.group_duty_pin.t") as mock_t: + mock_t.return_value = "Group only" + await mod.refresh_pin_cmd(update, context) + update.message.reply_text.assert_called_once_with("Group only") + mock_t.assert_called_with("en", "refresh_pin.group_only") + + +@pytest.mark.asyncio +async def test_refresh_pin_cmd_group_updated_replies_updated(): + """refresh_pin_cmd in group with pin record -> _refresh_pin_for_chat returns updated -> reply refresh_pin.updated.""" + update = MagicMock() + update.message = MagicMock() + update.message.reply_text = AsyncMock() + update.effective_chat = MagicMock() + update.effective_chat.type = "group" + update.effective_chat.id = 100 + update.effective_user = MagicMock() + context = MagicMock() + + with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"): + with patch.object( + mod, "_refresh_pin_for_chat", AsyncMock(return_value="updated") + ): + with patch("duty_teller.handlers.group_duty_pin.t") as mock_t: + mock_t.return_value = "Updated" + await mod.refresh_pin_cmd(update, context) + update.message.reply_text.assert_called_once_with("Updated") + mock_t.assert_called_with("en", "refresh_pin.updated") + + +@pytest.mark.asyncio +async def test_refresh_pin_cmd_group_no_message_replies_no_message(): + """refresh_pin_cmd: no pin record -> _refresh_pin_for_chat returns no_message -> reply refresh_pin.no_message.""" + update = MagicMock() + update.message = MagicMock() + update.message.reply_text = AsyncMock() + update.effective_chat = MagicMock() + update.effective_chat.type = "group" + update.effective_chat.id = 100 + update.effective_user = MagicMock() + context = MagicMock() + + with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"): + with patch.object( + mod, "_refresh_pin_for_chat", AsyncMock(return_value="no_message") + ): + with patch("duty_teller.handlers.group_duty_pin.t") as mock_t: + mock_t.return_value = "No message" + await mod.refresh_pin_cmd(update, context) + update.message.reply_text.assert_called_once_with("No message") + mock_t.assert_called_with("en", "refresh_pin.no_message") + + +@pytest.mark.asyncio +async def test_refresh_pin_cmd_group_edit_raises_replies_failed(): + """refresh_pin_cmd: _refresh_pin_for_chat returns failed (e.g. BadRequest) -> reply refresh_pin.failed.""" + update = MagicMock() + update.message = MagicMock() + update.message.reply_text = AsyncMock() + update.effective_chat = MagicMock() + update.effective_chat.type = "group" + update.effective_chat.id = 100 + update.effective_user = MagicMock() + context = MagicMock() + + with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"): + with patch.object( + mod, "_refresh_pin_for_chat", AsyncMock(return_value="failed") + ): + with patch("duty_teller.handlers.group_duty_pin.t") as mock_t: + mock_t.return_value = "Failed" + await mod.refresh_pin_cmd(update, context) + update.message.reply_text.assert_called_once_with("Failed") + mock_t.assert_called_with("en", "refresh_pin.failed") + + # --- my_chat_member_handler --- diff --git a/tests/test_personal_calendar_ics.py b/tests/test_personal_calendar_ics.py index 1ad717b..5a00366 100644 --- a/tests/test_personal_calendar_ics.py +++ b/tests/test_personal_calendar_ics.py @@ -46,7 +46,7 @@ def test_build_personal_ics_one_duty(): def test_build_team_ics_vevent_has_description_with_full_name(): - """Team ICS VEVENT includes DESCRIPTION with the duty holder's full_name.""" + """Team ICS VEVENT has SUMMARY=full_name and DESCRIPTION=full_name.""" duties = [ _duty(1, "2025-02-20T09:00:00Z", "2025-02-20T18:00:00Z", "duty"), ] @@ -56,11 +56,28 @@ def test_build_team_ics_vevent_has_description_with_full_name(): 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("summary") == "Test User" assert ev.get("description") == "Test User" assert b"Team Calendar" in ics or "Team Calendar" in ics.decode("utf-8") +def test_build_team_ics_empty_full_name_summary_fallback(): + """When full_name is empty or missing, SUMMARY is 'Duty'.""" + duty_ns = SimpleNamespace( + id=1, + start_at="2025-02-20T09:00:00Z", + end_at="2025-02-20T18:00:00Z", + event_type="duty", + ) + for empty_name in ("", " ", None): + duties = [(duty_ns, empty_name)] + ics = build_team_ics(duties) + cal = ICalendar.from_ical(ics) + events = [c for c in cal.walk() if c.name == "VEVENT"] + assert len(events) == 1 + assert events[0].get("summary") == "Duty" + + def test_build_personal_ics_event_types(): """Unavailable and vacation get correct SUMMARY.""" duties = [ diff --git a/webapp/js/constants.js b/webapp/js/constants.js index f6102b5..aebb484 100644 --- a/webapp/js/constants.js +++ b/webapp/js/constants.js @@ -5,5 +5,3 @@ export const FETCH_TIMEOUT_MS = 15000; export const RETRY_DELAY_MS = 800; export const RETRY_AFTER_ACCESS_DENIED_MS = 1200; - -export const THEME_BG = { dark: "#17212b", light: "#d5d6db" };