diff --git a/duty_teller/api/app.py b/duty_teller/api/app.py index 9770b16..1361288 100644 --- a/duty_teller/api/app.py +++ b/duty_teller/api/app.py @@ -5,6 +5,8 @@ import re from datetime import date, timedelta import duty_teller.config as config +from starlette.middleware.base import BaseHTTPMiddleware + from fastapi import Depends, FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import Response @@ -56,6 +58,22 @@ app.add_middleware( ) +class NoCacheStaticMiddleware(BaseHTTPMiddleware): + """Set Cache-Control for /app/*.js and /app/*.html so WebView gets fresh JS (i18n, etc.).""" + + async def dispatch(self, request, call_next): + response = await call_next(request) + path = request.url.path + if path.startswith("/app/") and ( + path.endswith(".js") or path.endswith(".html") + ): + response.headers["Cache-Control"] = "no-store" + return response + + +app.add_middleware(NoCacheStaticMiddleware) + + @app.get( "/api/duties", response_model=list[DutyWithUser], @@ -126,7 +144,9 @@ def get_team_calendar_ical( 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" + (d, name) + for d, name, *_ in all_duties + if (d.event_type or "duty") == "duty" ] ics_bytes = build_team_ics(duties_duty_only) ics_calendar_cache.set(cache_key, ics_bytes) diff --git a/duty_teller/api/dependencies.py b/duty_teller/api/dependencies.py index ee8cc89..835adeb 100644 --- a/duty_teller/api/dependencies.py +++ b/duty_teller/api/dependencies.py @@ -190,7 +190,7 @@ def fetch_duties_response( to_date: End date YYYY-MM-DD. Returns: - List of DutyWithUser (id, user_id, start_at, end_at, full_name, event_type). + List of DutyWithUser (id, user_id, start_at, end_at, full_name, event_type, phone, username). """ rows = get_duties(session, from_date=from_date, to_date=to_date) return [ @@ -203,6 +203,8 @@ def fetch_duties_response( event_type=( duty.event_type if duty.event_type in DUTY_EVENT_TYPES else "duty" ), + phone=phone, + username=username, ) - for duty, full_name in rows + for duty, full_name, phone, username in rows ] diff --git a/duty_teller/config.py b/duty_teller/config.py index d9a4987..574bcc6 100644 --- a/duty_teller/config.py +++ b/duty_teller/config.py @@ -52,6 +52,7 @@ class Settings: bot_token: str database_url: str + bot_username: str mini_app_base_url: str http_host: str http_port: int @@ -93,9 +94,13 @@ class Settings: ) raw_host = (os.getenv("HTTP_HOST") or "127.0.0.1").strip() http_host = raw_host if raw_host else "127.0.0.1" + bot_username = ( + (os.getenv("BOT_USERNAME", "") or "").strip().lstrip("@").lower() + ) return cls( bot_token=bot_token, database_url=os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db"), + bot_username=bot_username, mini_app_base_url=os.getenv("MINI_APP_BASE_URL", "").rstrip("/"), http_host=http_host, http_port=int(os.getenv("HTTP_PORT", "8080")), @@ -123,6 +128,7 @@ _settings = Settings.from_env() BOT_TOKEN = _settings.bot_token DATABASE_URL = _settings.database_url +BOT_USERNAME = _settings.bot_username MINI_APP_BASE_URL = _settings.mini_app_base_url HTTP_HOST = _settings.http_host HTTP_PORT = _settings.http_port diff --git a/duty_teller/db/repository.py b/duty_teller/db/repository.py index 2dee3d3..d6fe68c 100644 --- a/duty_teller/db/repository.py +++ b/duty_teller/db/repository.py @@ -317,8 +317,8 @@ def get_duties( session: Session, from_date: str, to_date: str, -) -> list[tuple[Duty, str]]: - """Return duties overlapping the given date range with user full_name. +) -> list[tuple[Duty, str, str | None, str | None]]: + """Return duties overlapping the given date range with user full_name, phone, username. Args: session: DB session. @@ -326,11 +326,11 @@ def get_duties( to_date: End date YYYY-MM-DD. Returns: - List of (Duty, full_name) tuples. + List of (Duty, full_name, phone, username) tuples. """ to_date_next = to_date_exclusive_iso(to_date) q = ( - session.query(Duty, User.full_name) + session.query(Duty, User.full_name, User.phone, User.username) .join(User, Duty.user_id == User.id) .filter(Duty.start_at < to_date_next, Duty.end_at >= from_date) ) @@ -343,7 +343,7 @@ def get_duties_for_user( from_date: str, to_date: str, event_types: list[str] | None = None, -) -> list[tuple[Duty, str]]: +) -> list[tuple[Duty, str, str | None, str | None]]: """Return duties for one user overlapping the date range. Optionally filter by event_type (e.g. "duty", "unavailable", "vacation"). @@ -357,7 +357,7 @@ def get_duties_for_user( event_types: If not None, only return duties whose event_type is in this list. Returns: - List of (Duty, full_name) tuples. + List of (Duty, full_name, phone, username) tuples. """ to_date_next = to_date_exclusive_iso(to_date) filters = [ @@ -368,7 +368,7 @@ def get_duties_for_user( if event_types is not None: filters.append(Duty.event_type.in_(event_types)) q = ( - session.query(Duty, User.full_name) + session.query(Duty, User.full_name, User.phone, User.username) .join(User, Duty.user_id == User.id) .filter(*filters) ) diff --git a/duty_teller/db/schemas.py b/duty_teller/db/schemas.py index fb55e8c..fdd943e 100644 --- a/duty_teller/db/schemas.py +++ b/duty_teller/db/schemas.py @@ -55,13 +55,16 @@ class DutyInDb(DutyBase): class DutyWithUser(DutyInDb): - """Duty with full_name and event_type for calendar display. + """Duty with full_name, event_type, and optional contact fields for calendar display. event_type: only these values are returned; unknown DB values are mapped to "duty" in the API. + phone and username are exposed only to authenticated Mini App users (role-gated). """ full_name: str event_type: Literal["duty", "unavailable", "vacation"] = "duty" + phone: str | None = None + username: str | None = None model_config = ConfigDict(from_attributes=True) diff --git a/duty_teller/handlers/group_duty_pin.py b/duty_teller/handlers/group_duty_pin.py index c53da0e..ca9e8e8 100644 --- a/duty_teller/handlers/group_duty_pin.py +++ b/duty_teller/handlers/group_duty_pin.py @@ -6,7 +6,7 @@ from datetime import datetime, timezone from typing import Literal import duty_teller.config as config -from telegram import Update +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.constants import ChatMemberStatus from telegram.error import BadRequest, Forbidden from telegram.ext import ChatMemberHandler, CommandHandler, ContextTypes @@ -81,6 +81,23 @@ def _sync_trust_group(chat_id: int, added_by_user_id: int | None) -> bool: return False +def _get_contact_button_markup(lang: str) -> InlineKeyboardMarkup | None: + """Return inline keyboard with 'View contacts' URL button, or None if BOT_USERNAME not set. + + Uses a t.me Mini App deep link so the app opens inside Telegram. Uses url (not web_app): + InlineKeyboardButton with web_app is allowed only in private chats, so in groups + Telegram returns Button_type_invalid. A plain URL button works everywhere. + """ + if not config.BOT_USERNAME: + return None + url = f"https://t.me/{config.BOT_USERNAME}?startapp=duty" + button = InlineKeyboardButton( + text=t(lang, "pin_duty.view_contacts"), + url=url, + ) + return InlineKeyboardMarkup([[button]]) + + def _sync_untrust_group(chat_id: int) -> tuple[bool, int | None]: """Remove group from trusted list. @@ -180,7 +197,11 @@ async def _refresh_pin_for_chat( return "no_message" old_message_id = message_id try: - msg = await context.bot.send_message(chat_id=chat_id, text=text) + msg = await context.bot.send_message( + chat_id=chat_id, + text=text, + reply_markup=_get_contact_button_markup(config.DEFAULT_LANGUAGE), + ) except (BadRequest, Forbidden) as e: logger.warning( "Failed to send duty message for pin refresh chat_id=%s: %s", chat_id, e @@ -260,7 +281,11 @@ async def my_chat_member_handler( None, lambda: _get_duty_message_text_sync(lang) ) try: - msg = await context.bot.send_message(chat_id=chat_id, text=text) + msg = await context.bot.send_message( + chat_id=chat_id, + text=text, + reply_markup=_get_contact_button_markup(lang), + ) except (BadRequest, Forbidden) as e: logger.warning("Failed to send duty message in chat_id=%s: %s", chat_id, e) return @@ -334,7 +359,11 @@ async def pin_duty_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No None, lambda: _get_duty_message_text_sync(lang) ) try: - msg = await context.bot.send_message(chat_id=chat_id, text=text) + msg = await context.bot.send_message( + chat_id=chat_id, + text=text, + reply_markup=_get_contact_button_markup(lang), + ) except (BadRequest, Forbidden) as e: logger.warning( "Failed to send duty message for pin_duty chat_id=%s: %s", chat_id, e @@ -422,7 +451,11 @@ async def trust_group_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None, lambda: _get_duty_message_text_sync(lang) ) try: - msg = await context.bot.send_message(chat_id=chat_id, text=text) + msg = await context.bot.send_message( + chat_id=chat_id, + text=text, + reply_markup=_get_contact_button_markup(lang), + ) except (BadRequest, Forbidden) as e: logger.warning( "Failed to send duty message after trust_group chat_id=%s: %s", diff --git a/duty_teller/i18n/messages.py b/duty_teller/i18n/messages.py index e14a566..ca485da 100644 --- a/duty_teller/i18n/messages.py +++ b/duty_teller/i18n/messages.py @@ -60,6 +60,7 @@ MESSAGES: dict[str, dict[str, str]] = { "administrator with «Pin messages» permission, then send /pin_duty in the " "chat — the current message will be pinned." ), + "pin_duty.view_contacts": "View contacts", "duty.no_duty": "No duty at the moment.", "duty.label": "Duty:", "import.admin_only": "Access for administrators only.", @@ -87,6 +88,12 @@ MESSAGES: dict[str, dict[str, str]] = { "api.access_denied": "Access denied", "dates.bad_format": "Parameters from and to must be in YYYY-MM-DD format", "dates.from_after_to": "from date must not be after to", + "contact.show": "Contacts", + "contact.back": "Back", + "current_duty.title": "Current Duty", + "current_duty.no_duty": "No one is on duty right now", + "current_duty.shift": "Shift", + "current_duty.back": "Back to calendar", }, "ru": { "start.greeting": "Привет! Я бот календаря дежурств. Используй /help для списка команд.", @@ -142,6 +149,7 @@ MESSAGES: dict[str, dict[str, str]] = { "pin_duty.could_not_pin_make_admin": "Сообщение о дежурстве отправлено, но закрепить его не удалось. " "Сделайте бота администратором с правом «Закреплять сообщения» (Pin messages), " "затем отправьте в чат команду /pin_duty — текущее сообщение будет закреплено.", + "pin_duty.view_contacts": "Контакты", "duty.no_duty": "Сейчас дежурства нет.", "duty.label": "Дежурство:", "import.admin_only": "Доступ только для администраторов.", @@ -162,5 +170,11 @@ MESSAGES: dict[str, dict[str, str]] = { "api.access_denied": "Доступ запрещён", "dates.bad_format": "Параметры from и to должны быть в формате YYYY-MM-DD", "dates.from_after_to": "Дата from не должна быть позже to", + "contact.show": "Контакты", + "contact.back": "Назад", + "current_duty.title": "Текущее дежурство", + "current_duty.no_duty": "Сейчас никто не дежурит", + "current_duty.shift": "Смена", + "current_duty.back": "Назад к календарю", }, } diff --git a/duty_teller/run.py b/duty_teller/run.py index dcb730f..9d7913e 100644 --- a/duty_teller/run.py +++ b/duty_teller/run.py @@ -11,6 +11,14 @@ from telegram.ext import ApplicationBuilder from duty_teller import config from duty_teller.config import require_bot_token from duty_teller.handlers import group_duty_pin, register_handlers + + +async def _resolve_bot_username(application) -> None: + """If BOT_USERNAME is not set from env, resolve it via get_me().""" + if not config.BOT_USERNAME: + me = await application.bot.get_me() + config.BOT_USERNAME = (me.username or "").lower() + logger.info("Resolved BOT_USERNAME from API: %s", config.BOT_USERNAME) from duty_teller.utils.http_client import safe_urlopen logging.basicConfig( @@ -69,6 +77,7 @@ def main() -> None: ApplicationBuilder() .token(config.BOT_TOKEN) .post_init(group_duty_pin.restore_group_pin_jobs) + .post_init(_resolve_bot_username) .build() ) register_handlers(app) diff --git a/duty_teller/services/group_duty_pin_service.py b/duty_teller/services/group_duty_pin_service.py index b824660..48d4fdc 100644 --- a/duty_teller/services/group_duty_pin_service.py +++ b/duty_teller/services/group_duty_pin_service.py @@ -85,10 +85,6 @@ def format_duty_message(duty, user, tz_name: str, lang: str = "en") -> str: f"🕐 {label} {time_range}", f"👤 {user.full_name}", ] - if user.phone: - lines.append(f"📞 {user.phone}") - if user.username: - lines.append(f"@{user.username}") return "\n".join(lines) diff --git a/tests/test_api_dependencies.py b/tests/test_api_dependencies.py index b27cd64..e68a2fd 100644 --- a/tests/test_api_dependencies.py +++ b/tests/test_api_dependencies.py @@ -71,3 +71,31 @@ class TestValidateDutyDates: assert exc_info.value.status_code == 400 assert exc_info.value.detail == "From after to message" mock_t.assert_called_with("ru", "dates.from_after_to") + + +class TestFetchDutiesResponse: + """Tests for fetch_duties_response (DutyWithUser list with phone, username).""" + + def test_fetch_duties_response_includes_phone_and_username(self): + """get_duties returns (Duty, full_name, phone, username); response has phone, username.""" + from types import SimpleNamespace + + from duty_teller.db.schemas import DutyWithUser + + duty = SimpleNamespace( + id=1, + user_id=10, + start_at="2025-01-15T09:00:00Z", + end_at="2025-01-15T18:00:00Z", + event_type="duty", + ) + rows = [(duty, "Alice", "+79001234567", "alice_dev")] + with patch.object(deps, "get_duties", return_value=rows): + result = deps.fetch_duties_response( + type("Session", (), {})(), "2025-01-01", "2025-01-31" + ) + assert len(result) == 1 + assert isinstance(result[0], DutyWithUser) + assert result[0].full_name == "Alice" + assert result[0].phone == "+79001234567" + assert result[0].username == "alice_dev" diff --git a/tests/test_app.py b/tests/test_app.py index c479418..1281f15 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -254,7 +254,8 @@ def test_duties_200_with_unknown_event_type_mapped_to_duty(client): ) def fake_get_duties(session, from_date, to_date): - return [(fake_duty, "User A")] + # get_duties returns (Duty, full_name, phone, username) tuples. + return [(fake_duty, "User A", "+79001234567", "user_a")] with patch("duty_teller.api.dependencies.get_duties", side_effect=fake_get_duties): r = client.get( @@ -266,6 +267,8 @@ def test_duties_200_with_unknown_event_type_mapped_to_duty(client): assert len(data) == 1 assert data[0]["event_type"] == "duty" assert data[0]["full_name"] == "User A" + assert data[0].get("phone") == "+79001234567" + assert data[0].get("username") == "user_a" def test_calendar_ical_team_404_invalid_token_format(client): @@ -311,7 +314,11 @@ def test_calendar_ical_team_200_only_duty_and_description( end_at="2026-06-16T18:00:00Z", event_type="vacation", ) - mock_get_duties.return_value = [(duty, "User A"), (non_duty, "User B")] + # get_duties returns (Duty, full_name, phone, username) tuples. + mock_get_duties.return_value = [ + (duty, "User A", None, None), + (non_duty, "User B", None, None), + ] mock_build_team_ics.return_value = b"BEGIN:VCALENDAR\r\nPRODID:Team\r\nVEVENT\r\nDESCRIPTION:User A\r\nEND:VCALENDAR" token = "y" * 43 @@ -371,7 +378,8 @@ def test_calendar_ical_200_returns_only_that_users_duties( end_at="2026-06-15T18:00:00Z", event_type="duty", ) - mock_get_duties.return_value = [(duty, "User A")] + # get_duties_for_user returns (Duty, full_name, phone, username) tuples. + mock_get_duties.return_value = [(duty, "User A", None, None)] mock_build_ics.return_value = ( b"BEGIN:VCALENDAR\r\nVEVENT\r\n2026-06-15\r\nEND:VCALENDAR" ) @@ -415,7 +423,8 @@ def test_calendar_ical_ignores_unknown_query_params( end_at="2026-06-15T18:00:00Z", event_type="duty", ) - mock_get_duties.return_value = [(duty, "User A")] + # get_duties_for_user returns (Duty, full_name, phone, username) tuples. + mock_get_duties.return_value = [(duty, "User A", None, None)] mock_build_ics.return_value = b"BEGIN:VCALENDAR\r\nEND:VCALENDAR" token = "z" * 43 diff --git a/tests/test_group_duty_pin_service.py b/tests/test_group_duty_pin_service.py index 7dc8b53..a2935d0 100644 --- a/tests/test_group_duty_pin_service.py +++ b/tests/test_group_duty_pin_service.py @@ -81,6 +81,7 @@ class TestFormatDutyMessage: assert result == "No duty" def test_with_duty_and_user_returns_formatted(self): + """Formatted message includes time range and full name only; no contact info (phone/username).""" duty = SimpleNamespace( start_at="2025-01-15T09:00:00Z", end_at="2025-01-15T18:00:00Z", @@ -94,9 +95,10 @@ class TestFormatDutyMessage: mock_t.side_effect = lambda lang, key: "Duty" if key == "duty.label" else "" result = svc.format_duty_message(duty, user, "Europe/Moscow", "ru") assert "Иван Иванов" in result - assert "+79001234567" in result or "79001234567" in result - assert "@ivan" in result assert "Duty" in result + # Contact info is restricted to Mini App; not shown in pinned group message. + assert "+79001234567" not in result and "79001234567" not in result + assert "@ivan" not in result class TestGetDutyMessageText: diff --git a/tests/test_handlers_group_duty_pin.py b/tests/test_handlers_group_duty_pin.py index ccfb7ae..33db262 100644 --- a/tests/test_handlers_group_duty_pin.py +++ b/tests/test_handlers_group_duty_pin.py @@ -11,6 +11,13 @@ import duty_teller.config as config from duty_teller.handlers import group_duty_pin as mod +@pytest.fixture(autouse=True) +def no_mini_app_url(): + """Ensure BOT_USERNAME is empty so duty messages are sent without contact button (reply_markup=None).""" + with patch.object(config, "BOT_USERNAME", ""): + yield + + class TestSyncWrappers: """Tests for _get_duty_message_text_sync, _sync_save_pin, _sync_delete_pin, _sync_get_message_id, _get_all_pin_chat_ids_sync.""" @@ -76,6 +83,30 @@ class TestSyncWrappers: # --- _schedule_next_update --- +def test_get_contact_button_markup_empty_username_returns_none(): + """_get_contact_button_markup: BOT_USERNAME empty -> returns None.""" + with patch.object(config, "BOT_USERNAME", ""): + assert mod._get_contact_button_markup("en") is None + + +def test_get_contact_button_markup_returns_markup_when_username_set(): + """_get_contact_button_markup: BOT_USERNAME set -> returns InlineKeyboardMarkup with t.me deep link (startapp=duty).""" + from telegram import InlineKeyboardMarkup + + with patch.object(config, "BOT_USERNAME", "MyDutyBot"): + with patch.object(mod, "t", return_value="View contacts"): + result = mod._get_contact_button_markup("en") + assert result is not None + assert isinstance(result, InlineKeyboardMarkup) + assert len(result.inline_keyboard) == 1 + assert len(result.inline_keyboard[0]) == 1 + btn = result.inline_keyboard[0][0] + assert btn.text == "View contacts" + assert btn.url.startswith("https://t.me/") + assert "startapp=duty" in btn.url + assert btn.url == "https://t.me/MyDutyBot?startapp=duty" + + @pytest.mark.asyncio async def test_schedule_next_update_job_queue_none_returns_early(): """_schedule_next_update: job_queue is None -> log and return, no run_once.""" @@ -151,7 +182,9 @@ async def test_update_group_pin_sends_new_unpins_pins_saves_schedules_next(): with patch.object(mod, "_schedule_next_update", AsyncMock()): with patch.object(mod, "_sync_save_pin") as mock_save: await mod.update_group_pin(context) - context.bot.send_message.assert_called_once_with(chat_id=123, text="Current duty") + context.bot.send_message.assert_called_once_with( + chat_id=123, text="Current duty", reply_markup=None + ) 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=999, disable_notification=False @@ -260,7 +293,9 @@ async def test_update_group_pin_repin_raises_still_schedules_next(): with patch.object(mod, "_sync_save_pin") as mock_save: with patch.object(mod, "logger") as mock_logger: await mod.update_group_pin(context) - context.bot.send_message.assert_called_once_with(chat_id=222, text="Text") + context.bot.send_message.assert_called_once_with( + chat_id=222, text="Text", reply_markup=None + ) mock_save.assert_not_called() mock_logger.warning.assert_called_once() assert "Unpin or pin" in mock_logger.warning.call_args[0][0] @@ -292,7 +327,9 @@ async def test_update_group_pin_duty_pin_notify_false_pins_silent(): ) as mock_schedule: with patch.object(mod, "_sync_save_pin") as mock_save: await mod.update_group_pin(context) - context.bot.send_message.assert_called_once_with(chat_id=333, text="Text") + context.bot.send_message.assert_called_once_with( + chat_id=333, text="Text", reply_markup=None + ) context.bot.unpin_chat_message.assert_called_once_with(chat_id=333) context.bot.pin_chat_message.assert_called_once_with( chat_id=333, message_id=777, disable_notification=True @@ -409,7 +446,9 @@ async def test_pin_duty_cmd_no_message_id_creates_sends_pins_saves_schedules_rep ) as mock_t: mock_t.return_value = "Pinned" await mod.pin_duty_cmd(update, context) - context.bot.send_message.assert_called_once_with(chat_id=100, text="Duty text") + context.bot.send_message.assert_called_once_with( + chat_id=100, text="Duty text", reply_markup=None + ) context.bot.pin_chat_message.assert_called_once_with( chat_id=100, message_id=42, disable_notification=True ) @@ -485,7 +524,9 @@ async def test_pin_duty_cmd_no_message_id_pin_raises_saves_and_replies_could_not ) as mock_t: mock_t.return_value = "Make me admin to pin" await mod.pin_duty_cmd(update, context) - context.bot.send_message.assert_called_once_with(chat_id=100, text="Duty") + context.bot.send_message.assert_called_once_with( + chat_id=100, text="Duty", reply_markup=None + ) mock_save.assert_called_once_with(100, 43) update.message.reply_text.assert_called_once_with("Make me admin to pin") mock_t.assert_called_with("en", "pin_duty.could_not_pin_make_admin") @@ -689,7 +730,9 @@ async def test_my_chat_member_handler_bot_added_sends_pins_and_schedules(): with patch.object(mod, "_get_next_shift_end_sync", return_value=None): with patch.object(mod, "_schedule_next_update", AsyncMock()): await mod.my_chat_member_handler(update, context) - context.bot.send_message.assert_called_once_with(chat_id=200, text="Duty text") + context.bot.send_message.assert_called_once_with( + chat_id=200, text="Duty text", reply_markup=None + ) context.bot.pin_chat_message.assert_called_once_with( chat_id=200, message_id=42, disable_notification=True ) @@ -744,7 +787,9 @@ async def test_my_chat_member_handler_trusted_group_sends_duty(): with patch.object(mod, "_get_next_shift_end_sync", return_value=None): with patch.object(mod, "_schedule_next_update", AsyncMock()): await mod.my_chat_member_handler(update, context) - context.bot.send_message.assert_called_once_with(chat_id=200, text="Duty text") + context.bot.send_message.assert_called_once_with( + chat_id=200, text="Duty text", reply_markup=None + ) context.bot.pin_chat_message.assert_called_once_with( chat_id=200, message_id=42, disable_notification=True ) @@ -920,7 +965,9 @@ async def test_trust_group_cmd_admin_adds_group(): await mod.trust_group_cmd(update, context) update.message.reply_text.assert_any_call("Added") mock_t.assert_any_call("en", "trust_group.added") - context.bot.send_message.assert_called_once_with(chat_id=100, text="Duty text") + context.bot.send_message.assert_called_once_with( + chat_id=100, text="Duty text", reply_markup=None + ) context.bot.pin_chat_message.assert_called_once_with( chat_id=100, message_id=50, disable_notification=True ) diff --git a/tests/test_import_duty_schedule_integration.py b/tests/test_import_duty_schedule_integration.py index 7b16bb5..dd8c034 100644 --- a/tests/test_import_duty_schedule_integration.py +++ b/tests/test_import_duty_schedule_integration.py @@ -77,7 +77,7 @@ def test_import_creates_users_and_duties(db_url): assert "2026-02-16T06:00:00Z" in starts assert "2026-02-17T06:00:00Z" in starts assert "2026-02-18T06:00:00Z" in starts - for d, _ in duties: + for d, *_ in duties: assert d.event_type == "duty" diff --git a/webapp/index.html b/webapp/index.html index c98e2f3..578acf0 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -24,8 +24,18 @@