Initial commit

This commit is contained in:
2026-01-01 23:00:52 +03:00
parent 9836ecfd42
commit ead8fbe1a0
12 changed files with 505 additions and 16 deletions
@@ -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 ###
+12 -1
View File
@@ -4,10 +4,15 @@ import logging
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode from aiogram.enums import ParseMode
from aiogram_dialog import setup_dialogs
from dishka import make_async_container from dishka import make_async_container
from dishka.integrations.aiogram import setup_dishka 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.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.di import DatabaseProvider
from trudex.infrastructure.utils.config import Config from trudex.infrastructure.utils.config import Config
@@ -26,10 +31,16 @@ async def main() -> None:
) )
dp = Dispatcher() dp = Dispatcher()
dp.message.middleware(RejectNotAdminMiddleware())
dp.message.middleware(RejectNotCreatorMiddleware())
dp.include_router(router) dp.include_router(router)
dp.include_router(admin_menu_dialog)
dp.include_router(creator_menu_dialog)
container = make_async_container(DatabaseProvider()) container = make_async_container(DatabaseProvider())
setup_dishka(container, dp) setup_dishka(container, dp, auto_inject=True)
setup_dialogs(dp)
logging.info("Бот запущен") logging.info("Бот запущен")
@@ -1 +0,0 @@
@@ -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"<b>👤 Информация о пользователе</b>\n\n"
f"<b>ID:</b> <code>{user.id}</code>\n"
f"<b>Имя:</b> {user.first_name}\n"
f"<b>Фамилия:</b> {last_name_str}\n"
f"<b>Username:</b> {username_str}\n"
f"<b>Группа:</b> {group_str}\n"
f"<b>Администратор:</b> {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("🔧 <b>Админ-панель</b>\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("<b>👥 Пользователи</b>\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("<b>Введите ID или @username пользователя:</b>"),
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,
),
)
@@ -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()
@@ -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"<b>👤 Информация о пользователе</b>\n\n"
f"<b>ID:</b> <code>{user.id}</code>\n"
f"<b>Имя:</b> {user.first_name}\n"
f"<b>Фамилия:</b> {last_name_str}\n"
f"<b>Username:</b> {username_str}\n"
f"<b>Группа:</b> {group_str}\n"
f"<b>Администратор:</b> {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"<b>{user.first_name}</b>\n{username_str}\nID: <code>{user.id}</code>"
}
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("👑 <b>Панель создателя</b>\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("<b>👥 Пользователи</b>\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("<b>Введите ID или @username пользователя:</b>"),
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("<b>⚠️ Подтверждение</b>\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,
),
)
@@ -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()
+29 -2
View File
@@ -1,9 +1,36 @@
from aiogram import Router from aiogram import Router
from aiogram.filters import CommandStart from aiogram.filters import Command, CommandStart
from aiogram.types import Message 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 = Router()
@router.message(CommandStart()) @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("Привет! Я бот для тестирования по охране труда.") 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)
@@ -1,11 +1,12 @@
from typing import Any, Callable
from collections.abc import Awaitable from collections.abc import Awaitable
from typing import Any, Callable
from aiogram import BaseMiddleware from aiogram import BaseMiddleware
from aiogram.types import Message, TelegramObject from aiogram.types import Message, TelegramObject
from dishka import AsyncContainer from dishka import AsyncContainer
from trudex.infrastructure.database.repo import UserRepository from trudex.infrastructure.database.repo import UserRepository
from trudex.infrastructure.utils.config import Config
class RejectNotAdminMiddleware(BaseMiddleware): class RejectNotAdminMiddleware(BaseMiddleware):
@@ -23,15 +24,19 @@ class RejectNotAdminMiddleware(BaseMiddleware):
container: AsyncContainer = data["dishka_container"] container: AsyncContainer = data["dishka_container"]
user_id = event.from_user.id user_id = event.from_user.id
admin_commands = ["/admin"] admin_commands = ["/admin"]
if event.text:
if event.text.strip() in admin_commands: if event.text and event.text.strip() in admin_commands:
users_dao: UserRepository = await container.get(UserRepository) config: Config = await container.get(Config)
admins = await users_dao.get_admins()
if user_id in [admin.id for admin in admins]: if user_id == config.bot.creator_id:
return await handler(event, data) return await handler(event, data)
else:
pass users_repo: UserRepository = await container.get(UserRepository)
else: is_admin = await users_repo.is_admin(user_id)
if is_admin:
return await handler(event, data) return await handler(event, data)
else:
return
return await handler(event, data) return await handler(event, data)
@@ -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)
+1
View File
@@ -31,6 +31,7 @@ class DatabaseProvider(Provider):
) -> AsyncIterable[AsyncSession]: ) -> AsyncIterable[AsyncSession]:
async with session_maker() as session: async with session_maker() as session:
yield session yield session
await session.commit()
@provide(scope=Scope.REQUEST) @provide(scope=Scope.REQUEST)
def get_user_dao(self, session: AsyncSession) -> UserDAO: def get_user_dao(self, session: AsyncSession) -> UserDAO: