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)