feat: enhance duty information handling with contact details and current duty view

- Added `bot_username` to settings for dynamic retrieval of the bot's username.
- Implemented `_resolve_bot_username` function to fetch the bot's username if not set, improving user experience in group chats.
- Updated `DutyWithUser` schema to include optional `phone` and `username` fields for enhanced duty information.
- Enhanced API responses to include contact details for users, ensuring better communication.
- Introduced a new current duty view in the web app, displaying active duty information along with contact options.
- Updated CSS styles for better presentation of contact information in duty cards.
- Added unit tests to verify the inclusion of contact details in API responses and the functionality of the current duty view.
This commit is contained in:
2026-03-02 16:09:08 +03:00
parent f8aceabab5
commit e3240d0981
25 changed files with 1126 additions and 44 deletions

View File

@@ -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
)