From d04bce19136256d4f71809f597a8dc20d7218b61 Mon Sep 17 00:00:00 2001 From: kolo Date: Sun, 4 Jan 2026 03:00:44 +0300 Subject: [PATCH] commit --- pyproject.toml | 3 + src/trudex/application/__main__.py | 50 +++++--------- .../bot/admin_dialogs/broadcast.py | 3 +- .../bot/admin_dialogs/create_test.py | 6 +- .../application/bot/admin_dialogs/groups.py | 6 +- .../bot/admin_dialogs/main_menu.py | 8 +-- .../bot/admin_dialogs/templates.py | 4 +- .../application/bot/admin_dialogs/tests.py | 7 +- .../application/bot/admin_dialogs/users.py | 6 +- .../bot/creator_dialogs/broadcast.py | 3 +- .../bot/creator_dialogs/create_test.py | 6 +- .../application/bot/creator_dialogs/groups.py | 6 +- .../bot/creator_dialogs/main_menu.py | 8 +-- .../bot/creator_dialogs/templates.py | 4 +- .../application/bot/creator_dialogs/tests.py | 7 +- .../application/bot/creator_dialogs/users.py | 6 +- src/trudex/application/bot/handlers.py | 40 +++++++++-- .../application/bot/user_dialogs/main_menu.py | 3 +- .../bot/user_dialogs/registration.py | 6 +- .../application/bot/user_dialogs/take_test.py | 2 +- src/trudex/domain/schemas.py | 9 ++- src/trudex/infrastructure/database/config.py | 3 +- .../infrastructure/database/dao/question.py | 15 +++-- .../infrastructure/database/dao/test.py | 62 ++++++++++++----- .../infrastructure/database/dao/user.py | 67 ++++++++++--------- .../infrastructure/database/dto/question.py | 2 +- .../database/dto/test_attempt.py | 3 +- src/trudex/infrastructure/database/models.py | 15 +---- .../infrastructure/database/repo/__init__.py | 3 +- .../database/repo/test_attempt.py | 6 +- src/trudex/infrastructure/di.py | 5 +- src/trudex/infrastructure/scheduling/tasks.py | 14 ++-- .../infrastructure/utils/bot_commands.py | 3 +- src/trudex/infrastructure/utils/broadcast.py | 12 +++- .../infrastructure/utils/test_id_to_hash.py | 3 +- 35 files changed, 213 insertions(+), 193 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cd614cd..826eb2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,9 @@ dev = [ [tool.pyright] typeCheckingMode = "standard" +[tool.isort] +line_length = 110 + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/src/trudex/application/__main__.py b/src/trudex/application/__main__.py index 0b0b213..45d3fa9 100644 --- a/src/trudex/application/__main__.py +++ b/src/trudex/application/__main__.py @@ -9,42 +9,26 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler 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.create_test import \ - admin_create_test_dialog -from trudex.application.bot.admin_dialogs.groups import \ - groups_dialog as admin_groups_dialog +from trudex.application.bot.admin_dialogs.broadcast import broadcast_dialog as admin_broadcast_dialog +from trudex.application.bot.admin_dialogs.create_test import admin_create_test_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.templates import \ - templates_dialog as admin_templates_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.templates import \ - templates_dialog as creator_templates_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 +from trudex.application.bot.admin_dialogs.templates import templates_dialog as admin_templates_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.templates import templates_dialog as creator_templates_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 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.application.bot.middlewares.reject_not_admin import RejectNotAdminMiddleware +from trudex.application.bot.middlewares.reject_not_creator import RejectNotCreatorMiddleware from trudex.application.bot.user_dialogs.deeplink import deeplink_dialog from trudex.application.bot.user_dialogs.main_menu import user_menu_dialog -from trudex.application.bot.user_dialogs.registration import \ - registration_dialog +from trudex.application.bot.user_dialogs.registration import registration_dialog from trudex.application.bot.user_dialogs.take_test import take_test_dialog from trudex.infrastructure.database.repo.user import UserRepository from trudex.infrastructure.di import DatabaseProvider, SchedulerProvider @@ -99,8 +83,6 @@ async def main() -> None: ) setup_dialogs(dp) setup_dishka(container, dp, auto_inject=True) - - bott = await container.get(Bot) async with container() as request_container: user_repo = await request_container.get(UserRepository) diff --git a/src/trudex/application/bot/admin_dialogs/broadcast.py b/src/trudex/application/bot/admin_dialogs/broadcast.py index 7f49806..44a82ba 100644 --- a/src/trudex/application/bot/admin_dialogs/broadcast.py +++ b/src/trudex/application/bot/admin_dialogs/broadcast.py @@ -6,8 +6,7 @@ from aiogram_dialog.widgets.text import Const from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.admin_dialogs.states import (AdminBroadcastSG, - AdminMenuSG) +from trudex.application.bot.admin_dialogs.states import AdminBroadcastSG, AdminMenuSG from trudex.infrastructure.database.dao.user import UserDAO from trudex.infrastructure.utils.broadcast import broadcast_message diff --git a/src/trudex/application/bot/admin_dialogs/create_test.py b/src/trudex/application/bot/admin_dialogs/create_test.py index cee34c2..006e48a 100644 --- a/src/trudex/application/bot/admin_dialogs/create_test.py +++ b/src/trudex/application/bot/admin_dialogs/create_test.py @@ -3,14 +3,12 @@ from datetime import date, datetime, time from aiogram.types import CallbackQuery, ContentType, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import (Button, Calendar, Cancel, Column, Row, - ScrollingGroup, Select) +from aiogram_dialog.widgets.kbd import Button, Calendar, 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.admin_dialogs.states import (AdminCreateTestSG, - AdminTestsSG) +from trudex.application.bot.admin_dialogs.states import AdminCreateTestSG, AdminTestsSG from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.option import OptionDAO from trudex.infrastructure.database.dao.question import QuestionDAO diff --git a/src/trudex/application/bot/admin_dialogs/groups.py b/src/trudex/application/bot/admin_dialogs/groups.py index 75bab05..8b80665 100644 --- a/src/trudex/application/bot/admin_dialogs/groups.py +++ b/src/trudex/application/bot/admin_dialogs/groups.py @@ -1,14 +1,12 @@ 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.kbd import Button, 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.admin_dialogs.states import (AdminGroupsSG, - AdminMenuSG) +from trudex.application.bot.admin_dialogs.states import AdminGroupsSG, AdminMenuSG from trudex.infrastructure.database.dao.group import GroupDAO diff --git a/src/trudex/application/bot/admin_dialogs/main_menu.py b/src/trudex/application/bot/admin_dialogs/main_menu.py index 33d9d36..c59ad27 100644 --- a/src/trudex/application/bot/admin_dialogs/main_menu.py +++ b/src/trudex/application/bot/admin_dialogs/main_menu.py @@ -3,12 +3,8 @@ from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.kbd import Button, Column from aiogram_dialog.widgets.text import Const -from trudex.application.bot.admin_dialogs.states import (AdminBroadcastSG, - AdminGroupsSG, - AdminMenuSG, - AdminTemplatesSG, - AdminTestsSG, - AdminUsersSG) +from trudex.application.bot.admin_dialogs.states import (AdminBroadcastSG, AdminGroupsSG, AdminMenuSG, + AdminTemplatesSG, AdminTestsSG, AdminUsersSG) async def on_tests_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: diff --git a/src/trudex/application/bot/admin_dialogs/templates.py b/src/trudex/application/bot/admin_dialogs/templates.py index 8529035..80329aa 100644 --- a/src/trudex/application/bot/admin_dialogs/templates.py +++ b/src/trudex/application/bot/admin_dialogs/templates.py @@ -10,13 +10,13 @@ from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.admin_dialogs.states import AdminMenuSG, AdminTemplatesSG, AdminTestsSG +from trudex.domain.schemas import QuestionType from trudex.domain.test_parser import ParsedTest, TestParser from trudex.infrastructure.database.dao.option import OptionDAO from trudex.infrastructure.database.dao.question import QuestionDAO from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.repo.test import TestRepository - TEMPLATES_INFO = ( "📦 Шаблоны тестов\n\n" "Шаблоны позволяют экспортировать и импортировать тесты в формате JSON.\n\n" @@ -227,7 +227,7 @@ async def on_test_selected_for_export( "question_type": question.question_type, } - if question.question_type == "input": + if question.question_type == QuestionType.INPUT: correct_options = [o for o in options if o.is_correct] if correct_options: question_data["correct_answer"] = correct_options[0].text diff --git a/src/trudex/application/bot/admin_dialogs/tests.py b/src/trudex/application/bot/admin_dialogs/tests.py index 7bfbe80..6cd66ab 100644 --- a/src/trudex/application/bot/admin_dialogs/tests.py +++ b/src/trudex/application/bot/admin_dialogs/tests.py @@ -6,15 +6,12 @@ from aiogram import Bot from aiogram.types import BufferedInputFile, CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import (Button, Calendar, Column, - ScrollingGroup, Select) +from aiogram_dialog.widgets.kbd import Button, Calendar, 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, - AdminTestsSG, - AdminCreateTestSG) +from trudex.application.bot.admin_dialogs.states import AdminCreateTestSG, AdminMenuSG, AdminTestsSG from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.repo.test import TestRepository diff --git a/src/trudex/application/bot/admin_dialogs/users.py b/src/trudex/application/bot/admin_dialogs/users.py index 34a08b6..55cba6b 100644 --- a/src/trudex/application/bot/admin_dialogs/users.py +++ b/src/trudex/application/bot/admin_dialogs/users.py @@ -1,14 +1,12 @@ 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, ScrollingGroup, Select, - SwitchTo) +from aiogram_dialog.widgets.kbd import Button, Column, ScrollingGroup, Select, SwitchTo 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, - AdminUsersSG) +from trudex.application.bot.admin_dialogs.states import AdminMenuSG, AdminUsersSG from trudex.infrastructure.database.dao.user import UserDAO diff --git a/src/trudex/application/bot/creator_dialogs/broadcast.py b/src/trudex/application/bot/creator_dialogs/broadcast.py index 52878fc..ba534fd 100644 --- a/src/trudex/application/bot/creator_dialogs/broadcast.py +++ b/src/trudex/application/bot/creator_dialogs/broadcast.py @@ -6,8 +6,7 @@ from aiogram_dialog.widgets.text import Const from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.creator_dialogs.states import (CreatorBroadcastSG, - CreatorMenuSG) +from trudex.application.bot.creator_dialogs.states import CreatorBroadcastSG, CreatorMenuSG from trudex.infrastructure.database.dao.user import UserDAO from trudex.infrastructure.utils.broadcast import broadcast_message diff --git a/src/trudex/application/bot/creator_dialogs/create_test.py b/src/trudex/application/bot/creator_dialogs/create_test.py index 513a21c..22ce7cb 100644 --- a/src/trudex/application/bot/creator_dialogs/create_test.py +++ b/src/trudex/application/bot/creator_dialogs/create_test.py @@ -3,14 +3,12 @@ from datetime import date, datetime, time from aiogram.types import CallbackQuery, ContentType, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import (Button, Calendar, Cancel, Column, Row, - ScrollingGroup, Select) +from aiogram_dialog.widgets.kbd import Button, Calendar, 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 (CreateTestSG, - CreatorTestsSG) +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 diff --git a/src/trudex/application/bot/creator_dialogs/groups.py b/src/trudex/application/bot/creator_dialogs/groups.py index a5565bd..b8ff31c 100644 --- a/src/trudex/application/bot/creator_dialogs/groups.py +++ b/src/trudex/application/bot/creator_dialogs/groups.py @@ -1,14 +1,12 @@ 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.kbd import Button, 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 (CreatorGroupsSG, - CreatorMenuSG) +from trudex.application.bot.creator_dialogs.states import CreatorGroupsSG, CreatorMenuSG from trudex.infrastructure.database.dao.group import GroupDAO diff --git a/src/trudex/application/bot/creator_dialogs/main_menu.py b/src/trudex/application/bot/creator_dialogs/main_menu.py index a37ebbf..b77a873 100644 --- a/src/trudex/application/bot/creator_dialogs/main_menu.py +++ b/src/trudex/application/bot/creator_dialogs/main_menu.py @@ -3,12 +3,8 @@ from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.kbd import Button, Column from aiogram_dialog.widgets.text import Const -from trudex.application.bot.creator_dialogs.states import (CreatorBroadcastSG, - CreatorGroupsSG, - CreatorMenuSG, - CreatorTemplatesSG, - CreatorTestsSG, - CreatorUsersSG) +from trudex.application.bot.creator_dialogs.states import (CreatorBroadcastSG, CreatorGroupsSG, CreatorMenuSG, + CreatorTemplatesSG, CreatorTestsSG, CreatorUsersSG) async def on_tests_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: diff --git a/src/trudex/application/bot/creator_dialogs/templates.py b/src/trudex/application/bot/creator_dialogs/templates.py index 396c774..9a12de9 100644 --- a/src/trudex/application/bot/creator_dialogs/templates.py +++ b/src/trudex/application/bot/creator_dialogs/templates.py @@ -10,13 +10,13 @@ from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.creator_dialogs.states import CreatorMenuSG, CreatorTemplatesSG, CreatorTestsSG +from trudex.domain.schemas import QuestionType from trudex.domain.test_parser import ParsedTest, TestParser from trudex.infrastructure.database.dao.option import OptionDAO from trudex.infrastructure.database.dao.question import QuestionDAO from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.repo.test import TestRepository - TEMPLATES_INFO = ( "📦 Шаблоны тестов\n\n" "Шаблоны позволяют экспортировать и импортировать тесты в формате JSON.\n\n" @@ -227,7 +227,7 @@ async def on_test_selected_for_export( "question_type": question.question_type, } - if question.question_type == "input": + if question.question_type == QuestionType.INPUT: correct_options = [o for o in options if o.is_correct] if correct_options: question_data["correct_answer"] = correct_options[0].text diff --git a/src/trudex/application/bot/creator_dialogs/tests.py b/src/trudex/application/bot/creator_dialogs/tests.py index 42969ab..6cd9ba4 100644 --- a/src/trudex/application/bot/creator_dialogs/tests.py +++ b/src/trudex/application/bot/creator_dialogs/tests.py @@ -8,16 +8,13 @@ from aiogram.types import BufferedInputFile, CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.api.entities import MediaAttachment from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import (Button, Calendar, Column, Row, - ScrollingGroup, Select) +from aiogram_dialog.widgets.kbd import Button, Calendar, Column, Row, ScrollingGroup, Select from aiogram_dialog.widgets.media import DynamicMedia 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 (CreateTestSG, - CreatorMenuSG, - CreatorTestsSG) +from trudex.application.bot.creator_dialogs.states import CreateTestSG, CreatorMenuSG, CreatorTestsSG from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.repo.test import TestRepository diff --git a/src/trudex/application/bot/creator_dialogs/users.py b/src/trudex/application/bot/creator_dialogs/users.py index 3d5e741..294f551 100644 --- a/src/trudex/application/bot/creator_dialogs/users.py +++ b/src/trudex/application/bot/creator_dialogs/users.py @@ -4,14 +4,12 @@ from aiogram import Bot 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, SwitchTo) +from aiogram_dialog.widgets.kbd import Button, Column, Row, ScrollingGroup, Select, SwitchTo 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, - CreatorUsersSG) +from trudex.application.bot.creator_dialogs.states import CreatorMenuSG, CreatorUsersSG from trudex.infrastructure.database.dao.user import UserDAO from trudex.infrastructure.database.repo.user import UserRepository from trudex.infrastructure.utils.bot_commands import setup_bot_commands diff --git a/src/trudex/application/bot/handlers.py b/src/trudex/application/bot/handlers.py index 75b0570..2b4d54f 100644 --- a/src/trudex/application/bot/handlers.py +++ b/src/trudex/application/bot/handlers.py @@ -1,5 +1,7 @@ +import logging + from aiogram import Router -from aiogram.filters import Command, CommandStart, CommandObject +from aiogram.filters import Command, CommandObject, CommandStart from aiogram.types import ErrorEvent, Message from aiogram_dialog import DialogManager, StartMode from aiogram_dialog.api.exceptions import OutdatedIntent, UnknownIntent @@ -7,11 +9,7 @@ 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.application.bot.user_dialogs.states import ( - UserDeeplinkSG, - UserMenuSG, - UserRegistrationSG, -) +from trudex.application.bot.user_dialogs.states import UserDeeplinkSG, UserMenuSG, UserRegistrationSG from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.dao.user import UserDAO @@ -20,6 +18,7 @@ from trudex.infrastructure.utils.test_id_to_hash import decode_id from trudex.infrastructure.utils.timezone import now_msk_naive router = Router() +logger = logging.getLogger(__name__) async def ensure_user_registered( @@ -115,6 +114,13 @@ async def start_with_deeplink( assert message.from_user is not None deeplink = command.args + logger.info( + "Deeplink start: user_id=%d, username=%s, deeplink=%s", + message.from_user.id, + message.from_user.username, + deeplink, + ) + if not deeplink: await start_handler(message, user_dao, group_dao, dialog_manager) return @@ -122,6 +128,7 @@ async def start_with_deeplink( try: test_id = decode_id(deeplink, config.security.encode_key) except (ValueError, IndexError): + logger.warning("Invalid deeplink: user_id=%d, deeplink=%s", message.from_user.id, deeplink) await message.answer("❌ Неверная ссылка на тест") await start_handler(message, user_dao, group_dao, dialog_manager) return @@ -138,6 +145,12 @@ async def start_with_deeplink( ) if not is_valid: + logger.info( + "Test validation failed: user_id=%d, test_id=%d, error=%s", + message.from_user.id, + test_id, + error, + ) await dialog_manager.start( UserDeeplinkSG.test_preview, mode=StartMode.RESET_STACK, @@ -145,6 +158,7 @@ async def start_with_deeplink( ) return + logger.info("User starting test via deeplink: user_id=%d, test_id=%d", message.from_user.id, test_id) await dialog_manager.start( UserDeeplinkSG.test_preview, mode=StartMode.RESET_STACK, @@ -159,6 +173,13 @@ async def start_handler( user_dao: FromDishka[UserDAO], group_dao: FromDishka[GroupDAO], ) -> None: + assert message.from_user is not None + logger.info( + "Start command: user_id=%d, username=%s", + message.from_user.id, + message.from_user.username, + ) + is_registered = await ensure_user_registered( user_dao, group_dao, message, dialog_manager ) @@ -169,15 +190,22 @@ async def start_handler( @router.message(Command("admin")) async def admin_command(_message: Message, dialog_manager: DialogManager) -> None: + assert _message.from_user is not None + logger.info("Admin panel access: user_id=%d", _message.from_user.id) await dialog_manager.start(AdminMenuSG.main, mode=StartMode.RESET_STACK) @router.message(Command("creator")) async def creator_command(_message: Message, dialog_manager: DialogManager) -> None: + assert _message.from_user is not None + logger.info("Creator panel access: user_id=%d", _message.from_user.id) await dialog_manager.start(CreatorMenuSG.main, mode=StartMode.RESET_STACK) @router.error() async def dialog_error_handler(event: ErrorEvent, dialog_manager: DialogManager) -> None: if isinstance(event.exception, (UnknownIntent, OutdatedIntent)): + logger.debug("Dialog intent error, resetting to main menu: %s", type(event.exception).__name__) await dialog_manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK) + else: + logger.exception("Unhandled error in dialog: %s", event.exception) diff --git a/src/trudex/application/bot/user_dialogs/main_menu.py b/src/trudex/application/bot/user_dialogs/main_menu.py index 21da133..1460415 100644 --- a/src/trudex/application/bot/user_dialogs/main_menu.py +++ b/src/trudex/application/bot/user_dialogs/main_menu.py @@ -1,6 +1,6 @@ import asyncio import functools -from datetime import timedelta +from datetime import datetime, timedelta from aiogram import Bot from aiogram.types import BufferedInputFile, CallbackQuery, Message @@ -21,7 +21,6 @@ from trudex.infrastructure.utils.config import Config from trudex.infrastructure.utils.qr_generator import generate_qr_bytes from trudex.infrastructure.utils.test_id_to_hash import encode_id from trudex.infrastructure.utils.timezone import now_msk, now_msk_naive, to_msk -from datetime import datetime def can_edit_field(updated_at: datetime | None) -> bool: diff --git a/src/trudex/application/bot/user_dialogs/registration.py b/src/trudex/application/bot/user_dialogs/registration.py index 916dcca..953cf23 100644 --- a/src/trudex/application/bot/user_dialogs/registration.py +++ b/src/trudex/application/bot/user_dialogs/registration.py @@ -6,11 +6,7 @@ from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.user_dialogs.states import ( - UserDeeplinkSG, - UserMenuSG, - UserRegistrationSG, -) +from trudex.application.bot.user_dialogs.states import UserDeeplinkSG, UserMenuSG, UserRegistrationSG from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.user import UserDAO diff --git a/src/trudex/application/bot/user_dialogs/take_test.py b/src/trudex/application/bot/user_dialogs/take_test.py index 6479df6..c1b6e6f 100644 --- a/src/trudex/application/bot/user_dialogs/take_test.py +++ b/src/trudex/application/bot/user_dialogs/take_test.py @@ -7,9 +7,9 @@ from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.user_dialogs.states import UserMenuSG, UserTestSG +from trudex.domain.schemas import QuestionType from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.dao.user_answer import UserAnswerDAO -from trudex.infrastructure.database.models import QuestionType from trudex.infrastructure.database.repo.test import TestRepository from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository from trudex.infrastructure.utils.timezone import now_msk_naive diff --git a/src/trudex/domain/schemas.py b/src/trudex/domain/schemas.py index 46878d1..74db228 100644 --- a/src/trudex/domain/schemas.py +++ b/src/trudex/domain/schemas.py @@ -1,5 +1,12 @@ from dataclasses import dataclass from datetime import datetime +from enum import Enum + + +class QuestionType(str, Enum): + SINGLE = "single" + MULTIPLE = "multiple" + INPUT = "input" @dataclass @@ -46,7 +53,7 @@ class Question: test_id: int text: str position: int = 0 - question_type: str = "single" + question_type: QuestionType = QuestionType.SINGLE tg_file_id: str | None = None diff --git a/src/trudex/infrastructure/database/config.py b/src/trudex/infrastructure/database/config.py index 8b3bd0f..fba9003 100644 --- a/src/trudex/infrastructure/database/config.py +++ b/src/trudex/infrastructure/database/config.py @@ -1,5 +1,4 @@ -from sqlalchemy.ext.asyncio import (AsyncSession, async_sessionmaker, - create_async_engine) +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine def new_session_maker(db_url: str) -> async_sessionmaker[AsyncSession]: diff --git a/src/trudex/infrastructure/database/dao/question.py b/src/trudex/infrastructure/database/dao/question.py index c67a60f..ac483c9 100644 --- a/src/trudex/infrastructure/database/dao/question.py +++ b/src/trudex/infrastructure/database/dao/question.py @@ -2,8 +2,9 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from trudex.domain.schemas import Question as DomainQuestion +from trudex.domain.schemas import QuestionType from trudex.infrastructure.database.dto.question import QuestionDTO -from trudex.infrastructure.database.models import Question, QuestionType +from trudex.infrastructure.database.models import Question class QuestionDAO: @@ -27,14 +28,16 @@ class QuestionDAO: test_id: int, text: str, position: int = 0, - question_type: str = "single", + question_type: str | QuestionType = QuestionType.SINGLE, tg_file_id: str | None = None, ) -> DomainQuestion: + if isinstance(question_type, str): + question_type = QuestionType(question_type) question = Question( test_id=test_id, text=text, position=position, - question_type=QuestionType(question_type), + question_type=question_type, tg_file_id=tg_file_id, ) self.session.add(question) @@ -47,7 +50,7 @@ class QuestionDAO: question_id: int, text: str | None = None, position: int | None = None, - question_type: str | None = None, + question_type: str | QuestionType | None = None, tg_file_id: str | None = None, ) -> DomainQuestion | None: result = await self.session.execute( @@ -62,7 +65,9 @@ class QuestionDAO: if position is not None: question.position = position if question_type is not None: - question.question_type = QuestionType(question_type) + if isinstance(question_type, str): + question_type = QuestionType(question_type) + question.question_type = question_type if tg_file_id is not None: question.tg_file_id = tg_file_id diff --git a/src/trudex/infrastructure/database/dao/test.py b/src/trudex/infrastructure/database/dao/test.py index 6e3d326..2694653 100644 --- a/src/trudex/infrastructure/database/dao/test.py +++ b/src/trudex/infrastructure/database/dao/test.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import NotRequired, TypedDict, Unpack from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -8,6 +9,25 @@ from trudex.infrastructure.database.dto.test import TestDTO from trudex.infrastructure.database.models import Test +class _UNSET: + """Sentinel для различения None и "не передано".""" + pass + + +UNSET = _UNSET() + + +class TestUpdateFields(TypedDict, total=False): + title: str + description: str | None + for_group: int | None + password: str | None + expires_at: datetime | None + attempts: int | None + is_active: bool + are_results_viewable: bool + + class TestDAO: def __init__(self, session: AsyncSession) -> None: self.session: AsyncSession = session @@ -26,6 +46,16 @@ class TestDAO: models = list(result.scalars().all()) return [TestDTO(model).to_domain() for model in models] + async def get_expired_active_tests(self, now: datetime) -> list[DomainTest]: + result = await self.session.execute( + select(Test) + .where(Test.is_active == True) + .where(Test.expires_at.isnot(None)) + .where(Test.expires_at < now) + ) + models = list(result.scalars().all()) + return [TestDTO(model).to_domain() for model in models] + async def create( self, title: str, @@ -55,14 +85,14 @@ class TestDAO: async def update( self, test_id: int, - title: str | None = None, - description: str | None = None, - for_group: int | None = None, - password: str | None = None, - expires_at: datetime | None = None, - attempts: int | None = None, - is_active: bool | None = None, - are_results_viewable: bool | None = None, + title: str | _UNSET = UNSET, + description: str | None | _UNSET = UNSET, + for_group: int | None | _UNSET = UNSET, + password: str | None | _UNSET = UNSET, + expires_at: datetime | None | _UNSET = UNSET, + attempts: int | None | _UNSET = UNSET, + is_active: bool | _UNSET = UNSET, + are_results_viewable: bool | _UNSET = UNSET, ) -> DomainTest | None: result = await self.session.execute( select(Test).where(Test.id == test_id) @@ -71,21 +101,21 @@ class TestDAO: if not test: return None - if title is not None: + if not isinstance(title, _UNSET): test.title = title - if description is not None: + if not isinstance(description, _UNSET): test.description = description - if for_group is not None: + if not isinstance(for_group, _UNSET): test.for_group = for_group - if password is not None: + if not isinstance(password, _UNSET): test.password = password - if expires_at is not None: + if not isinstance(expires_at, _UNSET): test.expires_at = expires_at - if attempts is not None: + if not isinstance(attempts, _UNSET): test.attempts = attempts - if is_active is not None: + if not isinstance(is_active, _UNSET): test.is_active = is_active - if are_results_viewable is not None: + if not isinstance(are_results_viewable, _UNSET): test.are_results_viewable = are_results_viewable await self.session.flush() diff --git a/src/trudex/infrastructure/database/dao/user.py b/src/trudex/infrastructure/database/dao/user.py index 712e2f9..8b88842 100644 --- a/src/trudex/infrastructure/database/dao/user.py +++ b/src/trudex/infrastructure/database/dao/user.py @@ -8,6 +8,14 @@ from trudex.infrastructure.database.dto.user import UserDTO from trudex.infrastructure.database.models import User +class _UNSET: + """Sentinel для различения None и "не передано".""" + pass + + +UNSET = _UNSET() + + class UserDAO: def __init__(self, session: AsyncSession) -> None: self.session: AsyncSession = session @@ -53,14 +61,14 @@ class UserDAO: async def update( self, user_id: int, - username: str | None = None, - first_name: str | None = None, - last_name: str | None = None, - name: str | None = None, - group: int | None = None, - is_admin: bool | None = None, - name_updated_at: datetime | None = None, - group_updated_at: datetime | None = None, + username: str | None | _UNSET = UNSET, + first_name: str | _UNSET = UNSET, + last_name: str | None | _UNSET = UNSET, + name: str | None | _UNSET = UNSET, + group: int | None | _UNSET = UNSET, + is_admin: bool | _UNSET = UNSET, + name_updated_at: datetime | None | _UNSET = UNSET, + group_updated_at: datetime | None | _UNSET = UNSET, ) -> DomainUser | None: result = await self.session.execute( select(User).where(User.id == user_id) @@ -69,21 +77,21 @@ class UserDAO: if not user: return None - if username is not None: + if not isinstance(username, _UNSET): user.username = username - if first_name is not None: + if not isinstance(first_name, _UNSET): user.first_name = first_name - if last_name is not None: + if not isinstance(last_name, _UNSET): user.last_name = last_name - if name is not None: + if not isinstance(name, _UNSET): user.name = name - if group is not None: + if not isinstance(group, _UNSET): user.group = group - if is_admin is not None: + if not isinstance(is_admin, _UNSET): user.is_admin = is_admin - if name_updated_at is not None: + if not isinstance(name_updated_at, _UNSET): user.name_updated_at = name_updated_at - if group_updated_at is not None: + if not isinstance(group_updated_at, _UNSET): user.group_updated_at = group_updated_at await self.session.flush() @@ -108,9 +116,9 @@ class UserDAO: first_name: str, username: str | None = None, last_name: str | None = None, - name: str | None = None, - group: int | None = None, - is_admin: bool | None = None, + name: str | None | _UNSET = UNSET, + group: int | None | _UNSET = UNSET, + is_admin: bool | _UNSET = UNSET, ) -> DomainUser: result = await self.session.execute( select(User).where(User.id == user_id) @@ -118,17 +126,14 @@ class UserDAO: user = result.scalar_one_or_none() if user: - if username is not None: - user.username = username - if first_name is not None: - user.first_name = first_name - if last_name is not None: - user.last_name = last_name - if name is not None: + user.username = username + user.first_name = first_name + user.last_name = last_name + if not isinstance(name, _UNSET): user.name = name - if group is not None: + if not isinstance(group, _UNSET): user.group = group - if is_admin is not None: + if not isinstance(is_admin, _UNSET): user.is_admin = is_admin await self.session.flush() await self.session.refresh(user) @@ -139,7 +144,7 @@ class UserDAO: username=username, first_name=first_name, last_name=last_name, - name=name, - group=group, - is_admin=is_admin or False, + name=name if not isinstance(name, _UNSET) else None, + group=group if not isinstance(group, _UNSET) else None, + is_admin=is_admin if not isinstance(is_admin, _UNSET) else False, ) diff --git a/src/trudex/infrastructure/database/dto/question.py b/src/trudex/infrastructure/database/dto/question.py index 6490f4b..a63bbbf 100644 --- a/src/trudex/infrastructure/database/dto/question.py +++ b/src/trudex/infrastructure/database/dto/question.py @@ -12,6 +12,6 @@ class QuestionDTO: test_id=self.model.test_id, text=self.model.text, position=self.model.position, - question_type=self.model.question_type.value, + question_type=self.model.question_type, tg_file_id=self.model.tg_file_id, ) diff --git a/src/trudex/infrastructure/database/dto/test_attempt.py b/src/trudex/infrastructure/database/dto/test_attempt.py index 9fb3255..786eb38 100644 --- a/src/trudex/infrastructure/database/dto/test_attempt.py +++ b/src/trudex/infrastructure/database/dto/test_attempt.py @@ -1,6 +1,5 @@ from trudex.domain.schemas import TestAttempt as DomainTestAttempt -from trudex.infrastructure.database.models import \ - TestAttempt as TestAttemptModel +from trudex.infrastructure.database.models import TestAttempt as TestAttemptModel class TestAttemptDTO: diff --git a/src/trudex/infrastructure/database/models.py b/src/trudex/infrastructure/database/models.py index 890dd7a..2d18dca 100644 --- a/src/trudex/infrastructure/database/models.py +++ b/src/trudex/infrastructure/database/models.py @@ -1,11 +1,11 @@ from datetime import datetime -from enum import Enum from typing import final -from sqlalchemy import (BigInteger, CheckConstraint, ForeignKey, Integer, - String, Text, func) +from sqlalchemy import BigInteger, CheckConstraint, ForeignKey, Integer, String, Text, func from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship +from trudex.domain.schemas import QuestionType + class Base(DeclarativeBase): pass @@ -40,15 +40,6 @@ class Group(Base): __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" - INPUT = "input" @final diff --git a/src/trudex/infrastructure/database/repo/__init__.py b/src/trudex/infrastructure/database/repo/__init__.py index 1f6a9b8..3d25dad 100644 --- a/src/trudex/infrastructure/database/repo/__init__.py +++ b/src/trudex/infrastructure/database/repo/__init__.py @@ -1,6 +1,5 @@ from trudex.infrastructure.database.repo.test import TestRepository -from trudex.infrastructure.database.repo.test_attempt import \ - TestAttemptRepository +from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository from trudex.infrastructure.database.repo.user import UserRepository __all__ = ["TestRepository", "TestAttemptRepository", "UserRepository"] diff --git a/src/trudex/infrastructure/database/repo/test_attempt.py b/src/trudex/infrastructure/database/repo/test_attempt.py index 1ec61fa..4842e4e 100644 --- a/src/trudex/infrastructure/database/repo/test_attempt.py +++ b/src/trudex/infrastructure/database/repo/test_attempt.py @@ -9,8 +9,7 @@ from trudex.infrastructure.database.dao.test_attempt import TestAttemptDAO from trudex.infrastructure.database.dao.user_answer import UserAnswerDAO from trudex.infrastructure.database.dto.test_attempt import TestAttemptDTO from trudex.infrastructure.database.dto.user_answer import UserAnswerDTO -from trudex.infrastructure.database.models import \ - TestAttempt as TestAttemptModel +from trudex.infrastructure.database.models import TestAttempt as TestAttemptModel from trudex.infrastructure.database.models import UserAnswer as UserAnswerModel from trudex.infrastructure.utils.timezone import now_msk_naive @@ -176,8 +175,7 @@ class TestAttemptRepository: } async def get_most_difficult_questions(self, test_id: int, limit: int = 10) -> list[tuple[int, float]]: - from trudex.infrastructure.database.models import \ - Question as QuestionModel + from trudex.infrastructure.database.models import Question as QuestionModel result = await self.session.execute( select( diff --git a/src/trudex/infrastructure/di.py b/src/trudex/infrastructure/di.py index fa4b406..e4706d3 100644 --- a/src/trudex/infrastructure/di.py +++ b/src/trudex/infrastructure/di.py @@ -1,5 +1,5 @@ -from collections.abc import AsyncIterable import logging +from collections.abc import AsyncIterable from apscheduler.schedulers.asyncio import AsyncIOScheduler from dishka import AsyncContainer, Provider, Scope, provide @@ -14,8 +14,7 @@ from trudex.infrastructure.database.dao.test_attempt import TestAttemptDAO from trudex.infrastructure.database.dao.user import UserDAO from trudex.infrastructure.database.dao.user_answer import UserAnswerDAO from trudex.infrastructure.database.repo.test import TestRepository -from trudex.infrastructure.database.repo.test_attempt import \ - TestAttemptRepository +from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository from trudex.infrastructure.database.repo.user import UserRepository from trudex.infrastructure.scheduling.tasks import deactivate_expired_tests from trudex.infrastructure.utils.config import Config diff --git a/src/trudex/infrastructure/scheduling/tasks.py b/src/trudex/infrastructure/scheduling/tasks.py index 5667ccb..85f91f7 100644 --- a/src/trudex/infrastructure/scheduling/tasks.py +++ b/src/trudex/infrastructure/scheduling/tasks.py @@ -1,15 +1,19 @@ +import logging + from dishka import AsyncContainer from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.utils.timezone import now_msk_naive +logger = logging.getLogger(__name__) -async def deactivate_expired_tests(container: AsyncContainer): + +async def deactivate_expired_tests(container: AsyncContainer) -> None: async with container() as request_container: test_dao = await request_container.get(TestDAO) - tests = await test_dao.get_all() + expired_tests = await test_dao.get_expired_active_tests(now_msk_naive()) - for test in tests: - if test.expires_at and test.expires_at < now_msk_naive() and test.is_active: - await test_dao.update(test.id, is_active=False) + for test in expired_tests: + await test_dao.update(test.id, is_active=False) + logger.info("Деактивирован истёкший тест: id=%d, title=%s", test.id, test.title) diff --git a/src/trudex/infrastructure/utils/bot_commands.py b/src/trudex/infrastructure/utils/bot_commands.py index 15b73ab..d017f64 100644 --- a/src/trudex/infrastructure/utils/bot_commands.py +++ b/src/trudex/infrastructure/utils/bot_commands.py @@ -1,6 +1,5 @@ from aiogram import Bot -from aiogram.types import (BotCommand, BotCommandScopeAllPrivateChats, - BotCommandScopeChat) +from aiogram.types import BotCommand, BotCommandScopeAllPrivateChats, BotCommandScopeChat from trudex.infrastructure.database.repo.user import UserRepository from trudex.infrastructure.utils.config import Config diff --git a/src/trudex/infrastructure/utils/broadcast.py b/src/trudex/infrastructure/utils/broadcast.py index 06612aa..a30c35f 100644 --- a/src/trudex/infrastructure/utils/broadcast.py +++ b/src/trudex/infrastructure/utils/broadcast.py @@ -1,4 +1,5 @@ import asyncio +import logging from dataclasses import dataclass from aiogram import Bot @@ -6,6 +7,8 @@ from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from trudex.infrastructure.database.dao.user import UserDAO +logger = logging.getLogger(__name__) + @dataclass class BroadcastStats: @@ -19,17 +22,20 @@ async def broadcast_message(bot: Bot, message_id: int, chat_id: int, user_dao: U success = 0 failed = 0 + logger.info("Starting broadcast: message_id=%d, total_users=%d", message_id, len(users)) + for user in users: try: await bot.copy_message(chat_id=user.id, from_chat_id=chat_id, message_id=message_id) success += 1 except TelegramForbiddenError: + logger.debug("Broadcast failed (forbidden): user_id=%d", user.id) failed += 1 - except TelegramBadRequest: - failed += 1 - except Exception: + except TelegramBadRequest as e: + logger.debug("Broadcast failed (bad request): user_id=%d, error=%s", user.id, e) failed += 1 await asyncio.sleep(0.1) + logger.info("Broadcast completed: success=%d, failed=%d, total=%d", success, failed, len(users)) return BroadcastStats(success=success, failed=failed, total=len(users)) diff --git a/src/trudex/infrastructure/utils/test_id_to_hash.py b/src/trudex/infrastructure/utils/test_id_to_hash.py index 2d9b035..d5322a0 100644 --- a/src/trudex/infrastructure/utils/test_id_to_hash.py +++ b/src/trudex/infrastructure/utils/test_id_to_hash.py @@ -1,8 +1,7 @@ -import hmac import hashlib +import hmac import string - ALPHABET = string.ascii_letters def _feistel_round(val: int, key: bytes, rounds: int) -> int: