diff --git a/alembic/versions/f63140aa50c0_test_attempts.py b/alembic/versions/f63140aa50c0_test_attempts.py
new file mode 100644
index 0000000..3e7c86d
--- /dev/null
+++ b/alembic/versions/f63140aa50c0_test_attempts.py
@@ -0,0 +1,54 @@
+"""test attempts
+
+Revision ID: f63140aa50c0
+Revises: 59dd00dc1990
+Create Date: 2026-01-01 16:26:43.398213
+
+"""
+from collections.abc import Sequence
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = 'f63140aa50c0'
+down_revision: str | None = '59dd00dc1990'
+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('test_attempts',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('user_id', sa.BigInteger(), nullable=False),
+ sa.Column('test_id', sa.Integer(), nullable=False),
+ sa.Column('started_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
+ sa.Column('finished_at', sa.DateTime(), nullable=True),
+ sa.Column('score', sa.Integer(), nullable=False),
+ sa.Column('is_passed', sa.Boolean(), nullable=False),
+ sa.ForeignKeyConstraint(['test_id'], ['tests.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_test_attempts_user_id'), 'test_attempts', ['user_id'], unique=False)
+ op.create_table('user_answers',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('attempt_id', sa.Integer(), nullable=False),
+ sa.Column('question_id', sa.Integer(), nullable=False),
+ sa.Column('selected_option_id', sa.Integer(), nullable=True),
+ sa.Column('text_answer', sa.Text(), nullable=True),
+ sa.Column('is_correct', sa.Boolean(), nullable=False),
+ sa.ForeignKeyConstraint(['attempt_id'], ['test_attempts.id'], ),
+ sa.ForeignKeyConstraint(['question_id'], ['questions.id'], ),
+ sa.ForeignKeyConstraint(['selected_option_id'], ['options.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('user_answers')
+ op.drop_index(op.f('ix_test_attempts_user_id'), table_name='test_attempts')
+ op.drop_table('test_attempts')
+ # ### end Alembic commands ###
diff --git a/src/trudex/application/__main__.py b/src/trudex/application/__main__.py
index e344ca8..87487e7 100644
--- a/src/trudex/application/__main__.py
+++ b/src/trudex/application/__main__.py
@@ -4,10 +4,15 @@ import logging
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
+from aiogram_dialog import setup_dialogs
from dishka import make_async_container
from dishka.integrations.aiogram import setup_dishka
+from trudex.application.bot.admin_dialogs.main_menu import admin_menu_dialog
+from trudex.application.bot.creator_dialogs.main_menu import creator_menu_dialog
from trudex.application.bot.handlers import router
+from trudex.application.bot.middlewares.reject_not_admin import RejectNotAdminMiddleware
+from trudex.application.bot.middlewares.reject_not_creator import RejectNotCreatorMiddleware
from trudex.infrastructure.di import DatabaseProvider
from trudex.infrastructure.utils.config import Config
@@ -26,10 +31,16 @@ async def main() -> None:
)
dp = Dispatcher()
+ dp.message.middleware(RejectNotAdminMiddleware())
+ dp.message.middleware(RejectNotCreatorMiddleware())
dp.include_router(router)
+ dp.include_router(admin_menu_dialog)
+ dp.include_router(creator_menu_dialog)
+
container = make_async_container(DatabaseProvider())
- setup_dishka(container, dp)
+ setup_dishka(container, dp, auto_inject=True)
+ setup_dialogs(dp)
logging.info("Бот запущен")
diff --git a/src/trudex/application/bot/admin_dialogs/__init__.py b/src/trudex/application/bot/admin_dialogs/__init__.py
index 8b13789..e69de29 100644
--- a/src/trudex/application/bot/admin_dialogs/__init__.py
+++ b/src/trudex/application/bot/admin_dialogs/__init__.py
@@ -1 +0,0 @@
-
diff --git a/src/trudex/application/bot/admin_dialogs/main_menu.py b/src/trudex/application/bot/admin_dialogs/main_menu.py
new file mode 100644
index 0000000..bf8b240
--- /dev/null
+++ b/src/trudex/application/bot/admin_dialogs/main_menu.py
@@ -0,0 +1,141 @@
+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 Back, Button, Column, ScrollingGroup, Select
+from aiogram_dialog.widgets.text import Const, Format
+from dishka import FromDishka
+from dishka.integrations.aiogram_dialog import inject
+
+from trudex.application.bot.admin_dialogs.states import AdminMenuSG
+from trudex.infrastructure.database.dao.user import UserDAO
+
+
+@inject
+async def get_users_data(user_dao: FromDishka[UserDAO], **_kwargs):
+ users = await user_dao.get_all()
+
+ return {
+ "users": [
+ (f"{u.first_name} (@{u.username or 'нет'})", u.id)
+ for u in users
+ ],
+ "count": len(users),
+ }
+
+
+@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")
+ if not user_id:
+ return {"user_info": "Пользователь не выбран"}
+
+ user = await user_dao.get_by_id(user_id)
+ if not user:
+ return {"user_info": "Пользователь не найден"}
+
+ username_str = f"@{user.username}" if user.username else "—"
+ last_name_str = user.last_name or "—"
+ group_str = str(user.group) if user.group else "—"
+ admin_status = "✅ Да" if user.is_admin else "❌ Нет"
+
+ user_info = (
+ f"👤 Информация о пользователе\n\n"
+ f"ID: {user.id}\n"
+ f"Имя: {user.first_name}\n"
+ f"Фамилия: {last_name_str}\n"
+ f"Username: {username_str}\n"
+ f"Группа: {group_str}\n"
+ f"Администратор: {admin_status}"
+ )
+
+ return {"user_info": user_info}
+
+
+async def on_user_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str):
+ manager.dialog_data["selected_user_id"] = int(item_id)
+ await manager.switch_to(AdminMenuSG.user_detail)
+
+
+async def on_input_mode(_callback: CallbackQuery, _button: Button, manager: DialogManager):
+ await manager.switch_to(AdminMenuSG.users_input)
+
+
+async def on_user_input(message: Message, _widget: MessageInput, manager: DialogManager):
+ from dishka.integrations.aiogram import CONTAINER_NAME
+ container = manager.middleware_data[CONTAINER_NAME]
+ user_dao = await container.get(UserDAO)
+
+ text = (message.text or "").strip()
+
+ user = None
+ if text.startswith("@"):
+ username = text[1:]
+ all_users = await user_dao.get_all()
+ user = next((u for u in all_users if u.username == username), None)
+ elif text.isdigit():
+ user = await user_dao.get_by_id(int(text))
+
+ if not user:
+ await message.answer("❌ Пользователь не найден в базе данных.")
+ return
+
+ manager.dialog_data["selected_user_id"] = user.id
+ await manager.switch_to(AdminMenuSG.user_detail)
+
+
+async def on_tests_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None:
+ await _callback.answer("Управление тестами")
+
+
+async def on_users_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None:
+ await manager.switch_to(AdminMenuSG.users_list)
+
+
+async def on_broadcast_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None:
+ await _callback.answer("Рассылка")
+
+
+admin_menu_dialog = Dialog(
+ Window(
+ Const("🔧 Админ-панель\n\nВыберите раздел:"),
+ Column(
+ Button(Const("📝 Тесты"), id="tests", on_click=on_tests_clicked),
+ Button(Const("👥 Пользователи"), id="users", on_click=on_users_clicked),
+ Button(Const("📢 Рассылка"), id="broadcast", on_click=on_broadcast_clicked),
+ ),
+ state=AdminMenuSG.main,
+ ),
+ Window(
+ Format("👥 Пользователи\n\nВсего: {count}"),
+ ScrollingGroup(
+ Select(
+ Format("{item[0]}"),
+ id="user_select",
+ item_id_getter=lambda x: x[1],
+ items="users",
+ on_click=on_user_selected,
+ ),
+ id="users_scroll",
+ width=1,
+ height=7,
+ ),
+ Column(
+ Button(Const("✏️ Ввести ID/Username"), id="input_mode", on_click=on_input_mode),
+ Back(Const("◀️ Назад")),
+ ),
+ state=AdminMenuSG.users_list,
+ getter=get_users_data,
+ ),
+ Window(
+ Const("Введите ID или @username пользователя:"),
+ MessageInput(on_user_input),
+ Back(Const("◀️ Назад")),
+ state=AdminMenuSG.users_input,
+ ),
+ Window(
+ Format("{user_info}"),
+ Back(Const("◀️ Назад")),
+ state=AdminMenuSG.user_detail,
+ getter=get_user_detail_data,
+ ),
+)
diff --git a/src/trudex/application/bot/admin_dialogs/states.py b/src/trudex/application/bot/admin_dialogs/states.py
new file mode 100644
index 0000000..22127fe
--- /dev/null
+++ b/src/trudex/application/bot/admin_dialogs/states.py
@@ -0,0 +1,8 @@
+from aiogram.fsm.state import State, StatesGroup
+
+
+class AdminMenuSG(StatesGroup):
+ main = State()
+ users_list = State()
+ users_input = State()
+ user_detail = State()
diff --git a/src/trudex/application/bot/creator_dialogs/__init__.py b/src/trudex/application/bot/creator_dialogs/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/trudex/application/bot/creator_dialogs/main_menu.py b/src/trudex/application/bot/creator_dialogs/main_menu.py
new file mode 100644
index 0000000..e651161
--- /dev/null
+++ b/src/trudex/application/bot/creator_dialogs/main_menu.py
@@ -0,0 +1,198 @@
+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 Back, Button, Cancel, Column, Row, ScrollingGroup, Select
+from aiogram_dialog.widgets.text import Const, Format
+from dishka import FromDishka
+from dishka.integrations.aiogram_dialog import inject
+
+from trudex.application.bot.creator_dialogs.states import CreatorMenuSG
+from trudex.infrastructure.database.dao.user import UserDAO
+
+
+@inject
+async def get_users_data(user_dao: FromDishka[UserDAO], **_kwargs):
+ users = await user_dao.get_all()
+
+ return {
+ "users": [
+ (f"{u.first_name} (@{u.username or 'нет'})", u.id)
+ for u in users
+ ],
+ "count": len(users),
+ }
+
+
+@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")
+ if not user_id:
+ return {"user_info": "Пользователь не выбран", "is_admin": True, "show_make_admin": False}
+
+ user = await user_dao.get_by_id(user_id)
+ if not user:
+ return {"user_info": "Пользователь не найден", "is_admin": True, "show_make_admin": False}
+
+ username_str = f"@{user.username}" if user.username else "—"
+ last_name_str = user.last_name or "—"
+ group_str = str(user.group) if user.group else "—"
+ admin_status = "✅ Да" if user.is_admin else "❌ Нет"
+
+ user_info = (
+ f"👤 Информация о пользователе\n\n"
+ f"ID: {user.id}\n"
+ f"Имя: {user.first_name}\n"
+ f"Фамилия: {last_name_str}\n"
+ f"Username: {username_str}\n"
+ f"Группа: {group_str}\n"
+ f"Администратор: {admin_status}"
+ )
+
+ return {
+ "user_info": user_info,
+ "is_admin": user.is_admin,
+ "show_make_admin": not user.is_admin,
+ }
+
+
+@inject
+async def get_confirm_data(dialog_manager: DialogManager, user_dao: FromDishka[UserDAO], **_kwargs):
+ user_id = dialog_manager.dialog_data.get("selected_user_id")
+ if not user_id:
+ return {"user_info": "Пользователь не выбран"}
+
+ user = await user_dao.get_by_id(user_id)
+ if not user:
+ return {"user_info": "Пользователь не найден"}
+
+ username_str = f"@{user.username}" if user.username else "—"
+ return {
+ "user_info": f"{user.first_name}\n{username_str}\nID: {user.id}"
+ }
+
+
+async def on_user_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str):
+ manager.dialog_data["selected_user_id"] = int(item_id)
+ await manager.switch_to(CreatorMenuSG.user_detail)
+
+
+async def on_input_mode(_callback: CallbackQuery, _button: Button, manager: DialogManager):
+ await manager.switch_to(CreatorMenuSG.users_input)
+
+
+async def on_user_input(message: Message, _widget: MessageInput, manager: DialogManager):
+ from dishka.integrations.aiogram import CONTAINER_NAME
+ container = manager.middleware_data[CONTAINER_NAME]
+ user_dao = await container.get(UserDAO)
+
+ text = (message.text or "").strip()
+
+ user = None
+ if text.startswith("@"):
+ username = text[1:]
+ all_users = await user_dao.get_all()
+ user = next((u for u in all_users if u.username == username), None)
+ elif text.isdigit():
+ user = await user_dao.get_by_id(int(text))
+
+ if not user:
+ await message.answer("❌ Пользователь не найден в базе данных.")
+ return
+
+ manager.dialog_data["selected_user_id"] = user.id
+ await manager.switch_to(CreatorMenuSG.user_detail)
+
+
+async def on_make_admin_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager):
+ await manager.switch_to(CreatorMenuSG.make_admin_confirm)
+
+
+async def on_confirm_yes(_callback: CallbackQuery, _button: Button, manager: DialogManager):
+ from dishka.integrations.aiogram import CONTAINER_NAME
+ container = manager.middleware_data[CONTAINER_NAME]
+ user_dao = await container.get(UserDAO)
+
+ user_id = manager.dialog_data.get("selected_user_id")
+ if not user_id:
+ await _callback.answer("Ошибка: пользователь не выбран")
+ return
+
+ await user_dao.update(user_id=user_id, is_admin=True)
+ await _callback.answer("✅ Пользователь назначен администратором")
+ await manager.switch_to(CreatorMenuSG.user_detail)
+
+
+async def on_confirm_no(_callback: CallbackQuery, _button: Button, manager: DialogManager):
+ await _callback.answer("Отменено")
+ await manager.switch_to(CreatorMenuSG.user_detail)
+
+
+async def on_tests_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None:
+ await _callback.answer("Тесты")
+
+
+async def on_users_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None:
+ await manager.switch_to(CreatorMenuSG.users_list)
+
+
+async def on_broadcast_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None:
+ await _callback.answer("Рассылка")
+
+
+creator_menu_dialog = Dialog(
+ Window(
+ Const("👑 Панель создателя\n\nВыберите раздел:"),
+ Column(
+ Button(Const("📝 Тесты"), id="tests", on_click=on_tests_clicked),
+ Button(Const("👥 Пользователи"), id="users", on_click=on_users_clicked),
+ Button(Const("📢 Рассылка"), id="broadcast", on_click=on_broadcast_clicked),
+ ),
+ state=CreatorMenuSG.main,
+ ),
+ Window(
+ Format("👥 Пользователи\n\nВсего: {count}"),
+ ScrollingGroup(
+ Select(
+ Format("{item[0]}"),
+ id="user_select",
+ item_id_getter=lambda x: x[1],
+ items="users",
+ on_click=on_user_selected,
+ ),
+ id="users_scroll",
+ width=1,
+ height=7,
+ ),
+ Column(
+ Button(Const("✏️ Ввести ID/Username"), id="input_mode", on_click=on_input_mode),
+ Cancel(Const("◀️ Назад")),
+ ),
+ state=CreatorMenuSG.users_list,
+ getter=get_users_data,
+ ),
+ Window(
+ Const("Введите ID или @username пользователя:"),
+ MessageInput(on_user_input),
+ Back(Const("◀️ Назад")),
+ state=CreatorMenuSG.users_input,
+ ),
+ Window(
+ Format("{user_info}"),
+ Column(
+ Button(Const("👑 Сделать администратором"), id="make_admin", on_click=on_make_admin_clicked, when="show_make_admin"),
+ Back(Const("◀️ Назад")),
+ ),
+ state=CreatorMenuSG.user_detail,
+ getter=get_user_detail_data,
+ ),
+ Window(
+ Const("⚠️ Подтверждение\n\nВы уверены, что хотите назначить этого пользователя администратором?\n"),
+ Format("{user_info}"),
+ Row(
+ Button(Const("✅ Да"), id="confirm_yes", on_click=on_confirm_yes),
+ Button(Const("❌ Нет"), id="confirm_no", on_click=on_confirm_no),
+ ),
+ state=CreatorMenuSG.make_admin_confirm,
+ getter=get_confirm_data,
+ ),
+)
diff --git a/src/trudex/application/bot/creator_dialogs/states.py b/src/trudex/application/bot/creator_dialogs/states.py
new file mode 100644
index 0000000..b8c1d05
--- /dev/null
+++ b/src/trudex/application/bot/creator_dialogs/states.py
@@ -0,0 +1,9 @@
+from aiogram.fsm.state import State, StatesGroup
+
+
+class CreatorMenuSG(StatesGroup):
+ main = State()
+ users_list = State()
+ users_input = State()
+ user_detail = State()
+ make_admin_confirm = State()
diff --git a/src/trudex/application/bot/handlers.py b/src/trudex/application/bot/handlers.py
index d66aea4..81aad48 100644
--- a/src/trudex/application/bot/handlers.py
+++ b/src/trudex/application/bot/handlers.py
@@ -1,9 +1,36 @@
from aiogram import Router
-from aiogram.filters import CommandStart
+from aiogram.filters import Command, CommandStart
from aiogram.types import Message
+from aiogram_dialog import DialogManager, StartMode
+from dishka.integrations.aiogram import FromDishka
+
+from trudex.application.bot.admin_dialogs.states import AdminMenuSG
+from trudex.application.bot.creator_dialogs.states import CreatorMenuSG
+from trudex.infrastructure.database.dao.user import UserDAO
+
router = Router()
+
@router.message(CommandStart())
-async def start_handler(message: Message) -> None:
+async def start_handler(message: Message, user_dao: FromDishka[UserDAO]) -> None:
+ assert message.from_user is not None
+
+ await user_dao.upsert(
+ user_id=message.from_user.id,
+ first_name=message.from_user.first_name,
+ username=message.from_user.username,
+ last_name=message.from_user.last_name,
+ )
+
await message.answer("Привет! Я бот для тестирования по охране труда.")
+
+
+@router.message(Command("admin"))
+async def admin_command(message: Message, dialog_manager: DialogManager) -> None:
+ await dialog_manager.start(AdminMenuSG.main, mode=StartMode.RESET_STACK)
+
+
+@router.message(Command("creator"))
+async def creator_command(message: Message, dialog_manager: DialogManager) -> None:
+ await dialog_manager.start(CreatorMenuSG.main, mode=StartMode.RESET_STACK)
diff --git a/src/trudex/application/bot/middlewares/reject_not_admin.py b/src/trudex/application/bot/middlewares/reject_not_admin.py
index c016590..a9d7f90 100644
--- a/src/trudex/application/bot/middlewares/reject_not_admin.py
+++ b/src/trudex/application/bot/middlewares/reject_not_admin.py
@@ -1,11 +1,12 @@
-from typing import Any, Callable
from collections.abc import Awaitable
+from typing import Any, Callable
from aiogram import BaseMiddleware
from aiogram.types import Message, TelegramObject
from dishka import AsyncContainer
from trudex.infrastructure.database.repo import UserRepository
+from trudex.infrastructure.utils.config import Config
class RejectNotAdminMiddleware(BaseMiddleware):
@@ -23,15 +24,19 @@ class RejectNotAdminMiddleware(BaseMiddleware):
container: AsyncContainer = data["dishka_container"]
user_id = event.from_user.id
admin_commands = ["/admin"]
- if event.text:
- if event.text.strip() in admin_commands:
- users_dao: UserRepository = await container.get(UserRepository)
- admins = await users_dao.get_admins()
- if user_id in [admin.id for admin in admins]:
- return await handler(event, data)
- else:
- pass
- else:
+
+ if event.text and event.text.strip() in admin_commands:
+ config: Config = await container.get(Config)
+
+ if user_id == config.bot.creator_id:
return await handler(event, data)
- else:
- return await handler(event, data)
+
+ users_repo: UserRepository = await container.get(UserRepository)
+ is_admin = await users_repo.is_admin(user_id)
+
+ if is_admin:
+ return await handler(event, data)
+
+ return
+
+ return await handler(event, data)
diff --git a/src/trudex/application/bot/middlewares/reject_not_creator.py b/src/trudex/application/bot/middlewares/reject_not_creator.py
new file mode 100644
index 0000000..8fded29
--- /dev/null
+++ b/src/trudex/application/bot/middlewares/reject_not_creator.py
@@ -0,0 +1,36 @@
+from collections.abc import Awaitable
+from typing import Any, Callable
+
+from aiogram import BaseMiddleware
+from aiogram.types import Message, TelegramObject
+from dishka import AsyncContainer
+
+from trudex.infrastructure.utils.config import Config
+
+
+class RejectNotCreatorMiddleware(BaseMiddleware):
+ async def __call__(
+ self,
+ handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
+ event: TelegramObject,
+ data: dict[str, Any],
+ ) -> Any:
+ if not isinstance(event, Message):
+ return await handler(event, data)
+
+ assert event.from_user is not None
+
+ container: AsyncContainer = data["dishka_container"]
+ user_id = event.from_user.id
+ creator_commands = ["/creator"]
+
+ if event.text and event.text.strip() in creator_commands:
+ config: Config = await container.get(Config)
+
+ if user_id == config.bot.creator_id:
+ return await handler(event, data)
+
+ await event.answer("У вас нет доступа к панели создателя.")
+ return
+
+ return await handler(event, data)
diff --git a/src/trudex/infrastructure/di.py b/src/trudex/infrastructure/di.py
index a6eb442..db73904 100644
--- a/src/trudex/infrastructure/di.py
+++ b/src/trudex/infrastructure/di.py
@@ -31,6 +31,7 @@ class DatabaseProvider(Provider):
) -> AsyncIterable[AsyncSession]:
async with session_maker() as session:
yield session
+ await session.commit()
@provide(scope=Scope.REQUEST)
def get_user_dao(self, session: AsyncSession) -> UserDAO: