diff --git a/src/quizzi/application/bot/admin_dialogs/main_menu.py b/src/quizzi/application/bot/admin_dialogs/main_menu.py index a0d1cb8..6fe0916 100644 --- a/src/quizzi/application/bot/admin_dialogs/main_menu.py +++ b/src/quizzi/application/bot/admin_dialogs/main_menu.py @@ -25,7 +25,7 @@ async def on_groups_clicked(_callback: CallbackQuery, _button: Button, manager: async def on_broadcast_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: - await manager.start(SharedBroadcastSG.broadcast_input) + await manager.start(SharedBroadcastSG.select_groups) async def on_templates_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: diff --git a/src/quizzi/application/bot/admin_dialogs/states.py b/src/quizzi/application/bot/admin_dialogs/states.py index b85bfbc..9188c71 100644 --- a/src/quizzi/application/bot/admin_dialogs/states.py +++ b/src/quizzi/application/bot/admin_dialogs/states.py @@ -7,6 +7,7 @@ class AdminMenuSG(StatesGroup): class AdminUsersSG(StatesGroup): users_list = State() + filter_by_group = State() users_input = State() user_detail = State() user_stats = State() diff --git a/src/quizzi/application/bot/admin_dialogs/users.py b/src/quizzi/application/bot/admin_dialogs/users.py index 7616cf3..3527330 100644 --- a/src/quizzi/application/bot/admin_dialogs/users.py +++ b/src/quizzi/application/bot/admin_dialogs/users.py @@ -7,6 +7,7 @@ from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject from quizzi.application.bot.admin_dialogs.states import AdminUsersSG +from quizzi.infrastructure.database.dao.group import GroupDAO from quizzi.infrastructure.database.dao.user import UserDAO from quizzi.infrastructure.database.repo.test import TestRepository from quizzi.infrastructure.database.repo.test_attempt import TestAttemptRepository @@ -14,19 +15,58 @@ from quizzi.infrastructure.utils.timezone import to_msk @inject -async def get_users_data(user_dao: FromDishka[UserDAO], **_kwargs): - users = await user_dao.get_all() +async def get_users_data(dialog_manager: DialogManager, user_dao: FromDishka[UserDAO], group_dao: FromDishka[GroupDAO], **_kwargs): + filter_group = dialog_manager.dialog_data.get("filter_group") + + if filter_group: + users = await user_dao.get_by_groups([filter_group]) + else: + users = await user_dao.get_all() + users_sorted = sorted(users, key=lambda u: u.created_at or u.id, reverse=True) + groups = await group_dao.get_all() + has_groups = len(groups) > 0 + + filter_text = f" (группа {filter_group})" if filter_group else "" + return { "users": [ (f"{'👑 ' if u.is_admin else ''}{u.name or u.first_name} (@{u.username or 'нет'})", u.id) for u in users_sorted ], "count": len(users_sorted), + "has_groups": has_groups, + "filter_text": filter_text, } +@inject +async def get_groups_filter_data(group_dao: FromDishka[GroupDAO], **_kwargs): + groups = await group_dao.get_all() + return { + "groups": [(str(g.id), str(g.number)) for g in groups], + } + + +async def on_filter_group_click(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(AdminUsersSG.filter_by_group) + + +@inject +async def on_group_filter_selected(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str, group_dao: FromDishka[GroupDAO]): + groups = await group_dao.get_all() + group = next((g for g in groups if str(g.id) == item_id), None) + if group: + manager.dialog_data["filter_group"] = group.number + await manager.switch_to(AdminUsersSG.users_list) + + +async def on_clear_filter(_callback: CallbackQuery, _button: Button, manager: DialogManager): + manager.dialog_data.pop("filter_group", None) + await manager.switch_to(AdminUsersSG.users_list) + + @inject async def get_user_detail_data(dialog_manager: DialogManager, user_dao: FromDishka[UserDAO], **_kwargs): user_id = dialog_manager.dialog_data.get("selected_user_id") @@ -207,7 +247,7 @@ async def get_user_result_detail( admin_users_dialog = Dialog( Window( - Format("👥 Пользователи\n\nВсего: {count}"), + Format("👥 Пользователи{filter_text}\n\nВсего: {count}"), ScrollingGroup( Select( Format("{item[0]}"), @@ -221,12 +261,34 @@ admin_users_dialog = Dialog( height=7, ), Column( + Button(Const("🔍 Фильтр по группе"), id="filter_group", on_click=on_filter_group_click, when="has_groups"), Button(Const("✏️ Ввести ID/Username"), id="input_mode", on_click=on_input_mode), Button(Const("◀️ Назад"), id="back", on_click=on_back_to_main), ), state=AdminUsersSG.users_list, getter=get_users_data, ), + Window( + Const("🔍 Фильтр по группе\n\nВыберите группу:"), + ScrollingGroup( + Select( + Format("{item[1]}"), + id="group_filter_select", + item_id_getter=lambda x: x[0], + items="groups", + on_click=on_group_filter_selected, + ), + id="groups_filter_scroll", + width=2, + height=5, + ), + Column( + Button(Const("🗑 Сбросить фильтр"), id="clear_filter", on_click=on_clear_filter), + SwitchTo(Const("◀️ Назад"), id="back_to_list", state=AdminUsersSG.users_list), + ), + state=AdminUsersSG.filter_by_group, + getter=get_groups_filter_data, + ), Window( Const("Введите ID или @username пользователя:"), MessageInput(on_user_input), diff --git a/src/quizzi/application/bot/creator_dialogs/main_menu.py b/src/quizzi/application/bot/creator_dialogs/main_menu.py index 941ff84..703a60b 100644 --- a/src/quizzi/application/bot/creator_dialogs/main_menu.py +++ b/src/quizzi/application/bot/creator_dialogs/main_menu.py @@ -25,7 +25,7 @@ async def on_groups_clicked(_callback: CallbackQuery, _button: Button, manager: async def on_broadcast_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: - await manager.start(SharedBroadcastSG.broadcast_input) + await manager.start(SharedBroadcastSG.select_groups) async def on_templates_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: diff --git a/src/quizzi/application/bot/creator_dialogs/states.py b/src/quizzi/application/bot/creator_dialogs/states.py index 7fae2b6..00a7db1 100644 --- a/src/quizzi/application/bot/creator_dialogs/states.py +++ b/src/quizzi/application/bot/creator_dialogs/states.py @@ -7,6 +7,7 @@ class CreatorMenuSG(StatesGroup): class CreatorUsersSG(StatesGroup): users_list = State() + filter_by_group = State() users_input = State() user_detail = State() user_stats = State() diff --git a/src/quizzi/application/bot/creator_dialogs/users.py b/src/quizzi/application/bot/creator_dialogs/users.py index e147d0b..78dd3e4 100644 --- a/src/quizzi/application/bot/creator_dialogs/users.py +++ b/src/quizzi/application/bot/creator_dialogs/users.py @@ -10,6 +10,7 @@ from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject from quizzi.application.bot.creator_dialogs.states import CreatorUsersSG +from quizzi.infrastructure.database.dao.group import GroupDAO from quizzi.infrastructure.database.dao.user import UserDAO from quizzi.infrastructure.database.repo.test import TestRepository from quizzi.infrastructure.database.repo.test_attempt import TestAttemptRepository @@ -20,19 +21,58 @@ from quizzi.infrastructure.utils.timezone import to_msk @inject -async def get_users_data(user_dao: FromDishka[UserDAO], **_kwargs): - users = await user_dao.get_all() +async def get_users_data(dialog_manager: DialogManager, user_dao: FromDishka[UserDAO], group_dao: FromDishka[GroupDAO], **_kwargs): + filter_group = dialog_manager.dialog_data.get("filter_group") + + if filter_group: + users = await user_dao.get_by_groups([filter_group]) + else: + users = await user_dao.get_all() + users_sorted = sorted(users, key=lambda u: u.created_at or u.id, reverse=True) + groups = await group_dao.get_all() + has_groups = len(groups) > 0 + + filter_text = f" (группа {filter_group})" if filter_group else "" + return { "users": [ (f"{'👑 ' if u.is_admin else ''}{u.name or u.first_name} (@{u.username or 'нет'})", u.id) for u in users_sorted ], "count": len(users_sorted), + "has_groups": has_groups, + "filter_text": filter_text, } +@inject +async def get_groups_filter_data(group_dao: FromDishka[GroupDAO], **_kwargs): + groups = await group_dao.get_all() + return { + "groups": [(str(g.id), str(g.number)) for g in groups], + } + + +async def on_filter_group_click(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(CreatorUsersSG.filter_by_group) + + +@inject +async def on_group_filter_selected(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str, group_dao: FromDishka[GroupDAO]): + groups = await group_dao.get_all() + group = next((g for g in groups if str(g.id) == item_id), None) + if group: + manager.dialog_data["filter_group"] = group.number + await manager.switch_to(CreatorUsersSG.users_list) + + +async def on_clear_filter(_callback: CallbackQuery, _button: Button, manager: DialogManager): + manager.dialog_data.pop("filter_group", None) + await manager.switch_to(CreatorUsersSG.users_list) + + @inject async def get_user_detail_data(dialog_manager: DialogManager, user_dao: FromDishka[UserDAO], **_kwargs): user_id = dialog_manager.dialog_data.get("selected_user_id") @@ -331,7 +371,7 @@ async def get_user_result_detail( creator_users_dialog = Dialog( Window( - Format("👥 Пользователи\n\nВсего: {count}"), + Format("👥 Пользователи{filter_text}\n\nВсего: {count}"), ScrollingGroup( Select( Format("{item[0]}"), @@ -345,12 +385,34 @@ creator_users_dialog = Dialog( height=7, ), Column( + Button(Const("🔍 Фильтр по группе"), id="filter_group", on_click=on_filter_group_click, when="has_groups"), Button(Const("✏️ Ввести ID/Username"), id="input_mode", on_click=on_input_mode), Button(Const("◀️ Назад"), id="back", on_click=on_back_to_main), ), state=CreatorUsersSG.users_list, getter=get_users_data, ), + Window( + Const("🔍 Фильтр по группе\n\nВыберите группу:"), + ScrollingGroup( + Select( + Format("{item[1]}"), + id="group_filter_select", + item_id_getter=lambda x: x[0], + items="groups", + on_click=on_group_filter_selected, + ), + id="groups_filter_scroll", + width=2, + height=5, + ), + Column( + Button(Const("🗑 Сбросить фильтр"), id="clear_filter", on_click=on_clear_filter), + SwitchTo(Const("◀️ Назад"), id="back_to_list", state=CreatorUsersSG.users_list), + ), + state=CreatorUsersSG.filter_by_group, + getter=get_groups_filter_data, + ), Window( Const("Введите ID или @username пользователя:"), MessageInput(on_user_input), diff --git a/src/quizzi/application/bot/shared_dialogs/broadcast.py b/src/quizzi/application/bot/shared_dialogs/broadcast.py index ff4fb4b..132204c 100644 --- a/src/quizzi/application/bot/shared_dialogs/broadcast.py +++ b/src/quizzi/application/bot/shared_dialogs/broadcast.py @@ -1,14 +1,66 @@ +from typing import TYPE_CHECKING + from aiogram.types import CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, Window from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import Button, Row -from aiogram_dialog.widgets.text import Const +from aiogram_dialog.widgets.kbd import Button, Column, Multiselect, Row, ScrollingGroup +from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject from quizzi.application.bot.shared_dialogs.states import SharedBroadcastSG +from quizzi.infrastructure.database.dao.group import GroupDAO from quizzi.service.broadcast import BroadcastService +from aiogram_dialog.widgets.kbd.select import ManagedMultiselect + + +@inject +async def get_groups_data(group_dao: FromDishka[GroupDAO], **_kwargs): + groups = await group_dao.get_all() + return { + "groups": [(str(g.id), str(g.number)) for g in groups], + "has_groups": len(groups) > 0, + } + + +async def on_group_selected( + _callback: CallbackQuery, + _widget, + manager: DialogManager, + _item_id: str, +): + pass + + +@inject +async def on_send_to_selected( + _callback: CallbackQuery, + _button: Button, + manager: DialogManager, + group_dao: FromDishka[GroupDAO], +): + multiselect: ManagedMultiselect[str] = manager.find("groups_multiselect") # type: ignore[assignment] + selected_ids = multiselect.get_checked() + + if not selected_ids: + await _callback.answer("❌ Выберите хотя бы одну группу", show_alert=True) + return + + groups = await group_dao.get_all() + id_to_number = {str(g.id): g.number for g in groups} + selected_numbers = [id_to_number[gid] for gid in selected_ids if gid in id_to_number] + + manager.dialog_data["selected_group_numbers"] = selected_numbers + manager.dialog_data["broadcast_to_all"] = False + await manager.switch_to(SharedBroadcastSG.broadcast_input) + + +async def on_send_to_all(_callback: CallbackQuery, _button: Button, manager: DialogManager): + manager.dialog_data["selected_group_numbers"] = None + manager.dialog_data["broadcast_to_all"] = True + await manager.switch_to(SharedBroadcastSG.broadcast_input) + async def on_broadcast_input(message: Message, _widget: MessageInput, manager: DialogManager): manager.dialog_data["broadcast_message_id"] = message.message_id @@ -16,6 +68,19 @@ async def on_broadcast_input(message: Message, _widget: MessageInput, manager: D await manager.switch_to(SharedBroadcastSG.broadcast_confirm) +async def get_confirm_data(dialog_manager: DialogManager, **_kwargs): + broadcast_to_all = dialog_manager.dialog_data.get("broadcast_to_all", False) + selected_numbers = dialog_manager.dialog_data.get("selected_group_numbers", []) + + if broadcast_to_all: + target_text = "всем пользователям" + else: + groups_str = ", ".join(str(n) for n in selected_numbers) + target_text = f"группам: {groups_str}" + + return {"target_text": target_text} + + @inject async def on_broadcast_confirm( _callback: CallbackQuery, @@ -37,7 +102,8 @@ async def on_broadcast_confirm( await _callback.answer("Ошибка: бот не найден") return - stats = await broadcast_service.broadcast_message(bot, message_id, chat_id) + group_numbers = manager.dialog_data.get("selected_group_numbers") + stats = await broadcast_service.broadcast_message(bot, message_id, chat_id, group_numbers) stats_text = ( f"✅ Рассылка завершена\n\n" @@ -55,23 +121,52 @@ async def on_broadcast_cancel(_callback: CallbackQuery, _button: Button, manager await manager.done() +async def on_back_to_groups(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(SharedBroadcastSG.select_groups) + + async def on_back_to_main(_callback: CallbackQuery, _button: Button, manager: DialogManager): await manager.done() shared_broadcast_dialog = Dialog( Window( - Const("📢 Рассылка\n\nОтправьте сообщение, которое хотите разослать всем пользователям:"), + Const("📢 Рассылка\n\nВыберите группы для рассылки:"), + ScrollingGroup( + Multiselect( + Format("✅ {item[1]}"), + Format("⬜ {item[1]}"), + id="groups_multiselect", + item_id_getter=lambda x: x[0], + items="groups", + on_click=on_group_selected, + ), + id="groups_scroll", + width=2, + height=5, + when="has_groups", + ), + Column( + Button(Const("📤 Отправить выбранным"), id="send_selected", on_click=on_send_to_selected, when="has_groups"), + Button(Const("📢 Отправить всем"), id="send_all", on_click=on_send_to_all), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_main), + ), + state=SharedBroadcastSG.select_groups, + getter=get_groups_data, + ), + Window( + Const("📢 Рассылка\n\nОтправьте сообщение, которое хотите разослать:"), MessageInput(on_broadcast_input), - Button(Const("◀️ Отмена"), id="back", on_click=on_back_to_main), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_groups), state=SharedBroadcastSG.broadcast_input, ), Window( - Const("⚠️ Подтверждение рассылки\n\nВы уверены, что хотите отправить это сообщение всем пользователям?"), + Format("⚠️ Подтверждение рассылки\n\nВы уверены, что хотите отправить это сообщение {target_text}?"), Row( Button(Const("✅ Да"), id="broadcast_confirm", on_click=on_broadcast_confirm), Button(Const("❌ Нет"), id="broadcast_cancel", on_click=on_broadcast_cancel), ), state=SharedBroadcastSG.broadcast_confirm, + getter=get_confirm_data, ), ) diff --git a/src/quizzi/application/bot/shared_dialogs/create_test.py b/src/quizzi/application/bot/shared_dialogs/create_test.py index 1ffcdde..5fe23c2 100644 --- a/src/quizzi/application/bot/shared_dialogs/create_test.py +++ b/src/quizzi/application/bot/shared_dialogs/create_test.py @@ -3,7 +3,7 @@ from datetime import date, datetime, time from aiogram.types import CallbackQuery, ContentType, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import Button, Calendar, Cancel, Column, Row, ScrollingGroup, Select +from aiogram_dialog.widgets.kbd import Button, Calendar, Column, Row, ScrollingGroup, Select from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject @@ -481,11 +481,15 @@ async def on_cancel(_callback: CallbackQuery, _button: Button, manager: DialogMa await manager.start(SharedTestsSG.tests_list, mode=StartMode.RESET_STACK) +async def on_back_to_menu(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.done() + + shared_create_test_dialog = Dialog( Window( Const("📝 Создание теста\n\n💬 Введите название теста:\n(максимум 255 символов)"), MessageInput(on_title_input), - Cancel(Const("◀️ Отмена")), + Button(Const("◀️ Отмена"), id="back", on_click=on_back_to_menu), state=SharedCreateTestSG.input_title, ), Window( diff --git a/src/quizzi/application/bot/shared_dialogs/states.py b/src/quizzi/application/bot/shared_dialogs/states.py index 50041a2..f1718d8 100644 --- a/src/quizzi/application/bot/shared_dialogs/states.py +++ b/src/quizzi/application/bot/shared_dialogs/states.py @@ -25,6 +25,7 @@ class SharedTestsSG(StatesGroup): class SharedBroadcastSG(StatesGroup): + select_groups = State() broadcast_input = State() broadcast_confirm = State() diff --git a/src/quizzi/infrastructure/database/dao/user.py b/src/quizzi/infrastructure/database/dao/user.py index 12c975f..d7cc091 100644 --- a/src/quizzi/infrastructure/database/dao/user.py +++ b/src/quizzi/infrastructure/database/dao/user.py @@ -40,6 +40,13 @@ class UserDAO: models = list(result.scalars().all()) return [UserDTO(model).to_domain() for model in models] + async def get_by_groups(self, group_numbers: list[int]) -> list[DomainUser]: + result = await self.session.execute( + select(User).where(User.group.in_(group_numbers)).order_by(User.created_at.desc()) + ) + models = list(result.scalars().all()) + return [UserDTO(model).to_domain() for model in models] + async def create( self, user_id: int, diff --git a/src/quizzi/service/broadcast.py b/src/quizzi/service/broadcast.py index ab8bcf2..e8e7b70 100644 --- a/src/quizzi/service/broadcast.py +++ b/src/quizzi/service/broadcast.py @@ -22,8 +22,12 @@ class BroadcastService: bot: Bot, message_id: int, from_chat_id: int, + group_numbers: list[int] | None = None, ) -> BroadcastStats: - users = await self._user_dao.get_all() + if group_numbers: + users = await self._user_dao.get_by_groups(group_numbers) + else: + users = await self._user_dao.get_all() total = len(users) success = 0