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: