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: