From 3a70802256e5c37d5dc05f3444b93b31ee37a5a7 Mon Sep 17 00:00:00 2001 From: kolo Date: Fri, 2 Jan 2026 20:18:42 +0300 Subject: [PATCH] Initial commit --- alembic/versions/520eccd2e55f_add_group.py | 38 ++++ src/trudex/application/__main__.py | 4 + .../application/bot/admin_dialogs/groups.py | 193 +++++++++++++++++ .../bot/admin_dialogs/main_menu.py | 7 +- .../application/bot/admin_dialogs/states.py | 7 + .../bot/creator_dialogs/create_test.py | 70 +++++-- .../application/bot/creator_dialogs/groups.py | 194 ++++++++++++++++++ .../bot/creator_dialogs/main_menu.py | 7 +- .../application/bot/creator_dialogs/states.py | 7 + src/trudex/domain/schemas.py | 8 + .../infrastructure/database/dao/group.py | 73 +++++++ .../infrastructure/database/dto/group.py | 15 ++ src/trudex/infrastructure/database/models.py | 17 ++ src/trudex/infrastructure/di.py | 5 + 14 files changed, 626 insertions(+), 19 deletions(-) create mode 100644 alembic/versions/520eccd2e55f_add_group.py create mode 100644 src/trudex/application/bot/admin_dialogs/groups.py create mode 100644 src/trudex/application/bot/creator_dialogs/groups.py create mode 100644 src/trudex/infrastructure/database/dao/group.py create mode 100644 src/trudex/infrastructure/database/dto/group.py diff --git a/alembic/versions/520eccd2e55f_add_group.py b/alembic/versions/520eccd2e55f_add_group.py new file mode 100644 index 0000000..bd5d52b --- /dev/null +++ b/alembic/versions/520eccd2e55f_add_group.py @@ -0,0 +1,38 @@ +"""add group + +Revision ID: 520eccd2e55f +Revises: d3bd5df63c1b +Create Date: 2026-01-02 19:42:09.264423 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +revision: str = '520eccd2e55f' +down_revision: str | None = 'd3bd5df63c1b' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('groups', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('number', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.CheckConstraint('number >= 1000 AND number <= 9999', name='check_group_number'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_groups_number'), 'groups', ['number'], unique=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_groups_number'), table_name='groups') + op.drop_table('groups') + # ### end Alembic commands ### diff --git a/src/trudex/application/__main__.py b/src/trudex/application/__main__.py index 4714fc2..72bc31b 100644 --- a/src/trudex/application/__main__.py +++ b/src/trudex/application/__main__.py @@ -9,11 +9,13 @@ from dishka import make_async_container from dishka.integrations.aiogram import setup_dishka from trudex.application.bot.admin_dialogs.broadcast import broadcast_dialog as admin_broadcast_dialog +from trudex.application.bot.admin_dialogs.groups import groups_dialog as admin_groups_dialog from trudex.application.bot.admin_dialogs.main_menu import admin_menu_dialog from trudex.application.bot.admin_dialogs.tests import tests_dialog as admin_tests_dialog from trudex.application.bot.admin_dialogs.users import users_dialog as admin_users_dialog from trudex.application.bot.creator_dialogs.broadcast import broadcast_dialog as creator_broadcast_dialog from trudex.application.bot.creator_dialogs.create_test import create_test_dialog +from trudex.application.bot.creator_dialogs.groups import groups_dialog as creator_groups_dialog from trudex.application.bot.creator_dialogs.main_menu import creator_menu_dialog from trudex.application.bot.creator_dialogs.tests import tests_dialog as creator_tests_dialog from trudex.application.bot.creator_dialogs.users import users_dialog as creator_users_dialog @@ -48,10 +50,12 @@ async def main() -> None: admin_menu_dialog, admin_users_dialog, admin_tests_dialog, + admin_groups_dialog, admin_broadcast_dialog, creator_menu_dialog, creator_users_dialog, creator_tests_dialog, + creator_groups_dialog, creator_broadcast_dialog, create_test_dialog, ) diff --git a/src/trudex/application/bot/admin_dialogs/groups.py b/src/trudex/application/bot/admin_dialogs/groups.py new file mode 100644 index 0000000..821bc5b --- /dev/null +++ b/src/trudex/application/bot/admin_dialogs/groups.py @@ -0,0 +1,193 @@ +from aiogram.types import CallbackQuery, Message +from aiogram_dialog import Dialog, DialogManager, StartMode, Window +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import Button, Column, Row, ScrollingGroup, Select +from aiogram_dialog.widgets.text import Const, Format +from dishka.integrations.aiogram import CONTAINER_NAME + +from trudex.application.bot.admin_dialogs.states import AdminGroupsSG, AdminMenuSG +from trudex.infrastructure.database.dao.group import GroupDAO + + +async def on_group_click(_callback: CallbackQuery, _widget, _manager: DialogManager, _item_id: str): + await _callback.answer("ℹ️ Для удаления используйте кнопку 'Удалить группу'") + + +async def get_groups_data(dialog_manager: DialogManager, **_kwargs): + container = dialog_manager.middleware_data[CONTAINER_NAME] + group_dao = await container.get(GroupDAO) + + groups = await group_dao.get_all() + + success_message = dialog_manager.dialog_data.pop("success_message", None) + + message_text = "👥 Управление группами\n\n" + if success_message: + message_text += f"{success_message}\n\n" + message_text += f"📊 Всего групп: {len(groups)}\n\nСписок групп:" + + return { + "groups": [(str(g.id), str(g.number)) for g in groups], + "groups_count": len(groups), + "message_text": message_text, + } + + +async def on_add_group(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(AdminGroupsSG.add_group_input_number) + + +async def on_delete_group(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(AdminGroupsSG.delete_groups_list) + + +async def on_back_to_menu(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.start(AdminMenuSG.main, mode=StartMode.RESET_STACK) + + +async def on_group_number_input(message: Message, _widget: MessageInput, manager: DialogManager): + if not message.text: + await message.answer("❌ Номер группы не может быть пустым") + return + + number_str = message.text.strip() + + if not number_str.isdigit(): + await message.answer("❌ Номер группы должен содержать только цифры") + return + + number = int(number_str) + + if number < 1000 or number > 9999: + await message.answer("❌ Номер группы должен быть четырехзначным (1000-9999)") + return + + container = manager.middleware_data[CONTAINER_NAME] + group_dao = await container.get(GroupDAO) + + existing = await group_dao.get_by_number(number) + if existing: + await message.answer(f"❌ Группа с номером {number} уже существует") + return + + try: + await group_dao.create(number=number) + manager.dialog_data["success_message"] = f"✅ Группа {number} создана" + except Exception as e: + await message.answer(f"❌ Ошибка создания группы: {e}") + return + + await manager.switch_to(AdminGroupsSG.groups_list) + + +async def on_cancel_add(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(AdminGroupsSG.groups_list) + + +async def get_delete_groups_data(dialog_manager: DialogManager, **_kwargs): + container = dialog_manager.middleware_data[CONTAINER_NAME] + group_dao = await container.get(GroupDAO) + + groups = await group_dao.get_all() + + return { + "groups": [(str(g.id), f"{g.number}") for g in groups], + "groups_count": len(groups), + } + + +async def on_select_group_to_delete(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str): + container = manager.middleware_data[CONTAINER_NAME] + group_dao = await container.get(GroupDAO) + + group = await group_dao.get_by_id(int(item_id)) + if not group: + await _callback.answer("❌ Группа не найдена", show_alert=True) + return + + manager.dialog_data["delete_group_id"] = group.id + manager.dialog_data["delete_group_number"] = group.number + await manager.switch_to(AdminGroupsSG.delete_confirm) + + +async def get_delete_confirm_data(dialog_manager: DialogManager, **_kwargs): + number = dialog_manager.dialog_data.get("delete_group_number", "") + + return { + "group_info": str(number) + } + + +async def on_confirm_delete(_callback: CallbackQuery, _button: Button, manager: DialogManager): + container = manager.middleware_data[CONTAINER_NAME] + group_dao = await container.get(GroupDAO) + + group_id = manager.dialog_data.get("delete_group_id") + + await group_dao.delete(group_id) + + manager.dialog_data["success_message"] = "✅ Группа удалена" + await manager.switch_to(AdminGroupsSG.groups_list) + + +async def on_cancel_delete(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(AdminGroupsSG.delete_groups_list) + + +groups_dialog = Dialog( + Window( + Format("{message_text}"), + ScrollingGroup( + Select( + Format("{item[1]}"), + id="groups", + item_id_getter=lambda x: x[0], + items="groups", + on_click=on_group_click, + ), + id="groups_scroll", + width=2, + height=7, + ), + Column( + Button(Const("➕ Добавить группу"), id="add", on_click=on_add_group), + Button(Const("🗑 Удалить группу"), id="delete", on_click=on_delete_group), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_menu), + ), + state=AdminGroupsSG.groups_list, + getter=get_groups_data, + ), + Window( + Const("➕ Добавление группы\n\n🔢 Введите номер группы (четырехзначное число 1000-9999):"), + MessageInput(on_group_number_input), + Button(Const("◀️ Отмена"), id="cancel", on_click=on_cancel_add), + state=AdminGroupsSG.add_group_input_number, + ), + Window( + Format("🗑 Удаление группы\n\nВыберите группу для удаления:\n\n📊 Всего групп: {groups_count}"), + ScrollingGroup( + Select( + Format("{item[1]}"), + id="delete_groups", + item_id_getter=lambda x: x[0], + items="groups", + on_click=on_select_group_to_delete, + ), + id="delete_groups_scroll", + width=2, + height=7, + ), + Button(Const("◀️ Назад"), id="back", on_click=on_cancel_add), + state=AdminGroupsSG.delete_groups_list, + getter=get_delete_groups_data, + ), + Window( + Format("⚠️ Подтверждение удаления\n\nТочно хотите удалить группу?\n\n👥 {group_info}"), + Row( + Button(Const("✅ Да, удалить"), id="confirm", on_click=on_confirm_delete), + Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel_delete), + ), + state=AdminGroupsSG.delete_confirm, + getter=get_delete_confirm_data, + ), +) diff --git a/src/trudex/application/bot/admin_dialogs/main_menu.py b/src/trudex/application/bot/admin_dialogs/main_menu.py index dc967bd..007c039 100644 --- a/src/trudex/application/bot/admin_dialogs/main_menu.py +++ b/src/trudex/application/bot/admin_dialogs/main_menu.py @@ -3,7 +3,7 @@ from aiogram_dialog import Dialog, DialogManager, Window, StartMode from aiogram_dialog.widgets.kbd import Button, Column from aiogram_dialog.widgets.text import Const -from trudex.application.bot.admin_dialogs.states import AdminMenuSG, AdminUsersSG, AdminTestsSG, AdminBroadcastSG +from trudex.application.bot.admin_dialogs.states import AdminMenuSG, AdminUsersSG, AdminTestsSG, AdminBroadcastSG, AdminGroupsSG async def on_tests_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: @@ -14,6 +14,10 @@ async def on_users_clicked(_callback: CallbackQuery, _button: Button, manager: D await manager.start(AdminUsersSG.users_list, mode=StartMode.RESET_STACK) +async def on_groups_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: + await manager.start(AdminGroupsSG.groups_list, mode=StartMode.RESET_STACK) + + async def on_broadcast_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: await manager.start(AdminBroadcastSG.broadcast_input, mode=StartMode.RESET_STACK) @@ -24,6 +28,7 @@ admin_menu_dialog = Dialog( Column( Button(Const("📝 Тесты"), id="tests", on_click=on_tests_clicked), Button(Const("👥 Пользователи"), id="users", on_click=on_users_clicked), + Button(Const("🎓 Группы"), id="groups", on_click=on_groups_clicked), Button(Const("📢 Рассылка"), id="broadcast", on_click=on_broadcast_clicked), ), state=AdminMenuSG.main, diff --git a/src/trudex/application/bot/admin_dialogs/states.py b/src/trudex/application/bot/admin_dialogs/states.py index 7bb6da9..312340b 100644 --- a/src/trudex/application/bot/admin_dialogs/states.py +++ b/src/trudex/application/bot/admin_dialogs/states.py @@ -18,3 +18,10 @@ class AdminTestsSG(StatesGroup): class AdminBroadcastSG(StatesGroup): broadcast_input = State() broadcast_confirm = State() + + +class AdminGroupsSG(StatesGroup): + groups_list = State() + add_group_input_number = State() + delete_groups_list = State() + delete_confirm = State() diff --git a/src/trudex/application/bot/creator_dialogs/create_test.py b/src/trudex/application/bot/creator_dialogs/create_test.py index c1e96e8..50c9cc0 100644 --- a/src/trudex/application/bot/creator_dialogs/create_test.py +++ b/src/trudex/application/bot/creator_dialogs/create_test.py @@ -3,12 +3,13 @@ from datetime import date, datetime from aiogram.types import CallbackQuery, ContentType, Message from aiogram_dialog import Dialog, DialogManager, Window, StartMode from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import Button, Calendar, Cancel, Column, Row, Select +from aiogram_dialog.widgets.kbd import Button, Calendar, Cancel, Column, Row, ScrollingGroup, Select from aiogram_dialog.widgets.text import Const, Format from dishka.integrations.aiogram import CONTAINER_NAME from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.creator_dialogs.states import CreateTestSG, CreatorTestsSG +from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.option import OptionDAO from trudex.infrastructure.database.dao.question import QuestionDAO from trudex.infrastructure.database.dao.test import TestDAO @@ -66,12 +67,29 @@ async def on_password_input(message: Message, _widget: MessageInput, manager: Di return manager.dialog_data["password"] = password - await manager.switch_to(CreateTestSG.input_expires_at) + + container = manager.middleware_data[CONTAINER_NAME] + group_dao = await container.get(GroupDAO) + groups = await group_dao.get_all() + + if len(groups) == 0: + manager.dialog_data["for_group"] = None + await manager.switch_to(CreateTestSG.confirm_test_info) + else: + await manager.switch_to(CreateTestSG.input_expires_at) async def on_skip_password(_callback: CallbackQuery, _button: Button, manager: DialogManager): manager.dialog_data["password"] = None - await manager.switch_to(CreateTestSG.input_expires_at) + container = manager.middleware_data[CONTAINER_NAME] + group_dao = await container.get(GroupDAO) + groups = await group_dao.get_all() + + if len(groups) == 0: + manager.dialog_data["for_group"] = None + await manager.switch_to(CreateTestSG.confirm_test_info) + else: + await manager.switch_to(CreateTestSG.input_expires_at) async def on_date_selected(_callback, _widget, manager: DialogManager, selected_date: date): @@ -84,13 +102,19 @@ async def on_skip_expires(_callback: CallbackQuery, _button: Button, manager: Di await manager.switch_to(CreateTestSG.input_for_group) -async def on_group_input(message: Message, _widget: MessageInput, manager: DialogManager): - text = (message.text or "").strip() - if text.isdigit() and len(text) == 4: - manager.dialog_data["for_group"] = int(text) - await manager.switch_to(CreateTestSG.confirm_test_info) - else: - await message.answer("❌ Группа должна быть 4-значным числом") +async def get_groups_for_test(dialog_manager: DialogManager, **_kwargs): + container = dialog_manager.middleware_data[CONTAINER_NAME] + group_dao = await container.get(GroupDAO) + groups = await group_dao.get_all() + + return { + "groups": [(str(g.number), str(g.number)) for g in groups], + } + + +async def on_group_selected(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str): + manager.dialog_data["for_group"] = int(item_id) + await manager.switch_to(CreateTestSG.confirm_test_info) async def on_skip_group(_callback: CallbackQuery, _button: Button, manager: DialogManager): @@ -183,7 +207,7 @@ async def get_question_type_data(**_kwargs): return { "question_types": [ ("single", "📌 Один правильный ответ"), - ("multiple", "� Ннесколько правильных ответов"), + ("multiple", "� Несколько правильных ответов"), ("input", "✏️ Ввод текста"), ] } @@ -423,10 +447,22 @@ create_test_dialog = Dialog( state=CreateTestSG.input_expires_at, ), Window( - Const("👥 Группа\n\n🎓 Введите номер группы (4 цифры) или пропустите для всех:"), - MessageInput(on_group_input), + Const("👥 Группа\n\n🎓 Выберите группу или пропустите для всех:"), + ScrollingGroup( + Select( + Format("{item[1]}"), + id="groups", + item_id_getter=lambda x: x[0], + items="groups", + on_click=on_group_selected, + ), + id="groups_scroll", + width=2, + height=7, + ), Button(Const("⏭️ Для всех"), id="skip_group", on_click=on_skip_group), state=CreateTestSG.input_for_group, + getter=get_groups_for_test, ), Window( Format("{info}\n\n✅ Подтвердите создание теста:"), @@ -454,13 +490,13 @@ create_test_dialog = Dialog( ), Window( Const("📋 Тип вопроса\n\n🎯 Выберите тип вопроса:"), - Select( + Column(Select( Format("{item[1]}"), id="question_type", item_id_getter=lambda x: x[0], items="question_types", on_click=on_question_type_selected, - ), + )), Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question), state=CreateTestSG.select_question_type, getter=get_question_type_data, @@ -481,13 +517,13 @@ create_test_dialog = Dialog( ), Window( Const("✅ Правильные ответы\n\nОтметьте правильные варианты ответов:"), - Select( + Column(Select( Format("{item[1]}"), id="options", item_id_getter=lambda x: x[0], items="options", on_click=on_option_toggle, - ), + )), Button(Const("✅ Подтвердить выбор"), id="confirm", on_click=on_confirm_correct), Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question), state=CreateTestSG.mark_correct_options, diff --git a/src/trudex/application/bot/creator_dialogs/groups.py b/src/trudex/application/bot/creator_dialogs/groups.py new file mode 100644 index 0000000..6f5bd97 --- /dev/null +++ b/src/trudex/application/bot/creator_dialogs/groups.py @@ -0,0 +1,194 @@ +from aiogram.types import CallbackQuery, Message +from aiogram_dialog import Dialog, DialogManager, StartMode, Window +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import Button, Column, Row, ScrollingGroup, Select +from aiogram_dialog.widgets.text import Const, Format +from dishka.integrations.aiogram import CONTAINER_NAME + +from trudex.application.bot.creator_dialogs.states import CreatorGroupsSG +from trudex.infrastructure.database.dao.group import GroupDAO + + +async def on_group_click(_callback: CallbackQuery, _widget, _manager: DialogManager, _item_id: str): + await _callback.answer("ℹ️ Для удаления используйте кнопку 'Удалить группу'") + + +async def get_groups_data(dialog_manager: DialogManager, **_kwargs): + container = dialog_manager.middleware_data[CONTAINER_NAME] + group_dao = await container.get(GroupDAO) + + groups = await group_dao.get_all() + + success_message = dialog_manager.dialog_data.pop("success_message", None) + + message_text = "👥 Управление группами\n\n" + if success_message: + message_text += f"{success_message}\n\n" + message_text += f"📊 Всего групп: {len(groups)}\n\nСписок групп:" + + return { + "groups": [(str(g.id), str(g.number)) for g in groups], + "groups_count": len(groups), + "message_text": message_text, + } + + +async def on_add_group(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(CreatorGroupsSG.add_group_input_number) + + +async def on_delete_group(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(CreatorGroupsSG.delete_groups_list) + + +async def on_back_to_menu(_callback: CallbackQuery, _button: Button, manager: DialogManager): + from trudex.application.bot.creator_dialogs.states import CreatorMenuSG + await manager.start(CreatorMenuSG.main, mode=StartMode.RESET_STACK) + + +async def on_group_number_input(message: Message, _widget: MessageInput, manager: DialogManager): + if not message.text: + await message.answer("❌ Номер группы не может быть пустым") + return + + number_str = message.text.strip() + + if not number_str.isdigit(): + await message.answer("❌ Номер группы должен содержать только цифры") + return + + number = int(number_str) + + if number < 1000 or number > 9999: + await message.answer("❌ Номер группы должен быть четырехзначным (1000-9999)") + return + + container = manager.middleware_data[CONTAINER_NAME] + group_dao = await container.get(GroupDAO) + + existing = await group_dao.get_by_number(number) + if existing: + await message.answer(f"❌ Группа с номером {number} уже существует") + return + + try: + await group_dao.create(number=number) + manager.dialog_data["success_message"] = f"✅ Группа {number} создана" + except Exception as e: + await message.answer(f"❌ Ошибка создания группы: {e}") + return + + await manager.switch_to(CreatorGroupsSG.groups_list) + + +async def on_cancel_add(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(CreatorGroupsSG.groups_list) + + +async def get_delete_groups_data(dialog_manager: DialogManager, **_kwargs): + container = dialog_manager.middleware_data[CONTAINER_NAME] + group_dao = await container.get(GroupDAO) + + groups = await group_dao.get_all() + + return { + "groups": [(str(g.id), str(g.number)) for g in groups], + "groups_count": len(groups), + } + + +async def on_select_group_to_delete(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str): + container = manager.middleware_data[CONTAINER_NAME] + group_dao = await container.get(GroupDAO) + + group = await group_dao.get_by_id(int(item_id)) + if not group: + await _callback.answer("❌ Группа не найдена", show_alert=True) + return + + manager.dialog_data["delete_group_id"] = group.id + manager.dialog_data["delete_group_number"] = group.number + await manager.switch_to(CreatorGroupsSG.delete_confirm) + + +async def get_delete_confirm_data(dialog_manager: DialogManager, **_kwargs): + number = dialog_manager.dialog_data.get("delete_group_number", "") + + return { + "group_info": str(number) + } + + +async def on_confirm_delete(_callback: CallbackQuery, _button: Button, manager: DialogManager): + container = manager.middleware_data[CONTAINER_NAME] + group_dao = await container.get(GroupDAO) + + group_id = manager.dialog_data.get("delete_group_id") + + await group_dao.delete(group_id) + + manager.dialog_data["success_message"] = "✅ Группа удалена" + await manager.switch_to(CreatorGroupsSG.groups_list) + + +async def on_cancel_delete(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(CreatorGroupsSG.delete_groups_list) + + +groups_dialog = Dialog( + Window( + Format("{message_text}"), + ScrollingGroup( + Select( + Format("{item[1]}"), + id="groups", + item_id_getter=lambda x: x[0], + items="groups", + on_click=on_group_click, + ), + id="groups_scroll", + width=2, + height=7, + ), + Column( + Button(Const("➕ Добавить группу"), id="add", on_click=on_add_group), + Button(Const("🗑 Удалить группу"), id="delete", on_click=on_delete_group), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_menu), + ), + state=CreatorGroupsSG.groups_list, + getter=get_groups_data, + ), + Window( + Const("➕ Добавление группы\n\n🔢 Введите номер группы (четырехзначное число 1000-9999):"), + MessageInput(on_group_number_input), + Button(Const("◀️ Отмена"), id="cancel", on_click=on_cancel_add), + state=CreatorGroupsSG.add_group_input_number, + ), + Window( + Format("🗑 Удаление группы\n\nВыберите группу для удаления:\n\n📊 Всего групп: {groups_count}"), + ScrollingGroup( + Select( + Format("{item[1]}"), + id="delete_groups", + item_id_getter=lambda x: x[0], + items="groups", + on_click=on_select_group_to_delete, + ), + id="delete_groups_scroll", + width=2, + height=7, + ), + Button(Const("◀️ Назад"), id="back", on_click=on_cancel_add), + state=CreatorGroupsSG.delete_groups_list, + getter=get_delete_groups_data, + ), + Window( + Format("⚠️ Подтверждение удаления\n\nТочно хотите удалить группу?\n\n👥 {group_info}"), + Row( + Button(Const("✅ Да, удалить"), id="confirm", on_click=on_confirm_delete), + Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel_delete), + ), + state=CreatorGroupsSG.delete_confirm, + getter=get_delete_confirm_data, + ), +) diff --git a/src/trudex/application/bot/creator_dialogs/main_menu.py b/src/trudex/application/bot/creator_dialogs/main_menu.py index 6159fcc..ccede3f 100644 --- a/src/trudex/application/bot/creator_dialogs/main_menu.py +++ b/src/trudex/application/bot/creator_dialogs/main_menu.py @@ -3,7 +3,7 @@ from aiogram_dialog import Dialog, DialogManager, Window, StartMode from aiogram_dialog.widgets.kbd import Button, Column from aiogram_dialog.widgets.text import Const -from trudex.application.bot.creator_dialogs.states import CreatorMenuSG, CreatorUsersSG, CreatorTestsSG, CreatorBroadcastSG +from trudex.application.bot.creator_dialogs.states import CreatorMenuSG, CreatorUsersSG, CreatorTestsSG, CreatorBroadcastSG, CreatorGroupsSG async def on_tests_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: @@ -14,6 +14,10 @@ async def on_users_clicked(_callback: CallbackQuery, _button: Button, manager: D await manager.start(CreatorUsersSG.users_list, mode=StartMode.RESET_STACK) +async def on_groups_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: + await manager.start(CreatorGroupsSG.groups_list, mode=StartMode.RESET_STACK) + + async def on_broadcast_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: await manager.start(CreatorBroadcastSG.broadcast_input, mode=StartMode.RESET_STACK) @@ -24,6 +28,7 @@ creator_menu_dialog = Dialog( Column( Button(Const("📝 Тесты"), id="tests", on_click=on_tests_clicked), Button(Const("👥 Пользователи"), id="users", on_click=on_users_clicked), + Button(Const("🎓 Группы"), id="groups", on_click=on_groups_clicked), Button(Const("📢 Рассылка"), id="broadcast", on_click=on_broadcast_clicked), ), state=CreatorMenuSG.main, diff --git a/src/trudex/application/bot/creator_dialogs/states.py b/src/trudex/application/bot/creator_dialogs/states.py index 20a2c2e..876135f 100644 --- a/src/trudex/application/bot/creator_dialogs/states.py +++ b/src/trudex/application/bot/creator_dialogs/states.py @@ -21,6 +21,13 @@ class CreatorBroadcastSG(StatesGroup): broadcast_confirm = State() +class CreatorGroupsSG(StatesGroup): + groups_list = State() + add_group_input_number = State() + delete_groups_list = State() + delete_confirm = State() + + class CreateTestSG(StatesGroup): input_title = State() input_description = State() diff --git a/src/trudex/domain/schemas.py b/src/trudex/domain/schemas.py index 2ea0e68..f8c4634 100644 --- a/src/trudex/domain/schemas.py +++ b/src/trudex/domain/schemas.py @@ -14,6 +14,14 @@ class User: updated_at: datetime | None = None +@dataclass +class Group: + id: int + number: int + created_at: datetime | None = None + updated_at: datetime | None = None + + @dataclass class Test: id: int diff --git a/src/trudex/infrastructure/database/dao/group.py b/src/trudex/infrastructure/database/dao/group.py new file mode 100644 index 0000000..42a0487 --- /dev/null +++ b/src/trudex/infrastructure/database/dao/group.py @@ -0,0 +1,73 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from trudex.domain.schemas import Group as DomainGroup +from trudex.infrastructure.database.dto.group import GroupDTO +from trudex.infrastructure.database.models import Group + + +class GroupDAO: + def __init__(self, session: AsyncSession) -> None: + self.session: AsyncSession = session + + async def get_by_id(self, group_id: int) -> DomainGroup | None: + result = await self.session.execute( + select(Group).where(Group.id == group_id) + ) + model = result.scalar_one_or_none() + return GroupDTO(model).to_domain() if model else None + + async def get_by_number(self, number: int) -> DomainGroup | None: + result = await self.session.execute( + select(Group).where(Group.number == number) + ) + model = result.scalar_one_or_none() + return GroupDTO(model).to_domain() if model else None + + async def get_all(self) -> list[DomainGroup]: + result = await self.session.execute(select(Group)) + models = list(result.scalars().all()) + return [GroupDTO(model).to_domain() for model in models] + + async def create( + self, + number: int, + ) -> DomainGroup: + group = Group( + number=number, + ) + self.session.add(group) + await self.session.flush() + await self.session.refresh(group) + return GroupDTO(group).to_domain() + + async def update( + self, + group_id: int, + number: int | None = None + ) -> DomainGroup | None: + result = await self.session.execute( + select(Group).where(Group.id == group_id) + ) + group = result.scalar_one_or_none() + if not group: + return None + + if number is not None: + group.number = number + + await self.session.flush() + await self.session.refresh(group) + return GroupDTO(group).to_domain() + + async def delete(self, group_id: int) -> bool: + result = await self.session.execute( + select(Group).where(Group.id == group_id) + ) + group = result.scalar_one_or_none() + if not group: + return False + + await self.session.delete(group) + await self.session.flush() + return True diff --git a/src/trudex/infrastructure/database/dto/group.py b/src/trudex/infrastructure/database/dto/group.py new file mode 100644 index 0000000..767e847 --- /dev/null +++ b/src/trudex/infrastructure/database/dto/group.py @@ -0,0 +1,15 @@ +from trudex.domain.schemas import Group as DomainGroup +from trudex.infrastructure.database.models import Group as GroupModel + + +class GroupDTO: + def __init__(self, model: GroupModel) -> None: + self.model: GroupModel = model + + def to_domain(self) -> DomainGroup: + return DomainGroup( + id=self.model.id, + number=self.model.number, + created_at=self.model.created_at, + updated_at=self.model.updated_at, + ) diff --git a/src/trudex/infrastructure/database/models.py b/src/trudex/infrastructure/database/models.py index fe7d7b9..bac500f 100644 --- a/src/trudex/infrastructure/database/models.py +++ b/src/trudex/infrastructure/database/models.py @@ -24,6 +24,23 @@ class User(Base): updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now()) +@final +class Group(Base): + __tablename__ = "groups" + + id: Mapped[int] = mapped_column(primary_key=True) + number: Mapped[int] = mapped_column(Integer, unique=True, index=True) + created_at: Mapped[datetime] = mapped_column(server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now()) + + __table_args__ = ( + CheckConstraint("number >= 1000 AND number <= 9999", name="check_group_number"), + ) + __table_args__ = ( + CheckConstraint("number >= 1000 AND number <= 9999", name="check_group_number"), + ) + + class QuestionType(str, Enum): SINGLE = "single" MULTIPLE = "multiple" diff --git a/src/trudex/infrastructure/di.py b/src/trudex/infrastructure/di.py index db73904..06756ed 100644 --- a/src/trudex/infrastructure/di.py +++ b/src/trudex/infrastructure/di.py @@ -4,6 +4,7 @@ from dishka import Provider, Scope, provide from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from trudex.infrastructure.database.config import new_session_maker +from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.option import OptionDAO from trudex.infrastructure.database.dao.question import QuestionDAO from trudex.infrastructure.database.dao.test import TestDAO @@ -37,6 +38,10 @@ class DatabaseProvider(Provider): def get_user_dao(self, session: AsyncSession) -> UserDAO: return UserDAO(session) + @provide(scope=Scope.REQUEST) + def get_group_dao(self, session: AsyncSession) -> GroupDAO: + return GroupDAO(session) + @provide(scope=Scope.REQUEST) def get_test_dao(self, session: AsyncSession) -> TestDAO: return TestDAO(session)