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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user