This commit is contained in:
2026-01-04 03:00:44 +03:00
parent b3237e2818
commit d04bce1913
35 changed files with 213 additions and 193 deletions
+3
View File
@@ -30,6 +30,9 @@ dev = [
[tool.pyright] [tool.pyright]
typeCheckingMode = "standard" typeCheckingMode = "standard"
[tool.isort]
line_length = 110
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
+16 -34
View File
@@ -9,42 +9,26 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
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.broadcast import \ from trudex.application.bot.admin_dialogs.broadcast import broadcast_dialog as admin_broadcast_dialog
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.create_test import \ from trudex.application.bot.admin_dialogs.groups import groups_dialog as admin_groups_dialog
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.main_menu import admin_menu_dialog
from trudex.application.bot.admin_dialogs.templates import \ from trudex.application.bot.admin_dialogs.templates import templates_dialog as admin_templates_dialog
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.tests import \ from trudex.application.bot.admin_dialogs.users import users_dialog as admin_users_dialog
tests_dialog as admin_tests_dialog from trudex.application.bot.creator_dialogs.broadcast import broadcast_dialog as creator_broadcast_dialog
from trudex.application.bot.admin_dialogs.users import \ from trudex.application.bot.creator_dialogs.create_test import create_test_dialog
users_dialog as admin_users_dialog from trudex.application.bot.creator_dialogs.groups import groups_dialog as creator_groups_dialog
from trudex.application.bot.creator_dialogs.broadcast import \ from trudex.application.bot.creator_dialogs.main_menu import creator_menu_dialog
broadcast_dialog as creator_broadcast_dialog from trudex.application.bot.creator_dialogs.templates import templates_dialog as creator_templates_dialog
from trudex.application.bot.creator_dialogs.create_test import \ from trudex.application.bot.creator_dialogs.tests import tests_dialog as creator_tests_dialog
create_test_dialog from trudex.application.bot.creator_dialogs.users import users_dialog as creator_users_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.handlers import router
from trudex.application.bot.middlewares.reject_not_admin import \ from trudex.application.bot.middlewares.reject_not_admin import RejectNotAdminMiddleware
RejectNotAdminMiddleware from trudex.application.bot.middlewares.reject_not_creator import RejectNotCreatorMiddleware
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.deeplink import deeplink_dialog
from trudex.application.bot.user_dialogs.main_menu import user_menu_dialog from trudex.application.bot.user_dialogs.main_menu import user_menu_dialog
from trudex.application.bot.user_dialogs.registration import \ from trudex.application.bot.user_dialogs.registration import registration_dialog
registration_dialog
from trudex.application.bot.user_dialogs.take_test import take_test_dialog from trudex.application.bot.user_dialogs.take_test import take_test_dialog
from trudex.infrastructure.database.repo.user import UserRepository from trudex.infrastructure.database.repo.user import UserRepository
from trudex.infrastructure.di import DatabaseProvider, SchedulerProvider from trudex.infrastructure.di import DatabaseProvider, SchedulerProvider
@@ -99,8 +83,6 @@ async def main() -> None:
) )
setup_dialogs(dp) setup_dialogs(dp)
setup_dishka(container, dp, auto_inject=True) setup_dishka(container, dp, auto_inject=True)
bott = await container.get(Bot)
async with container() as request_container: async with container() as request_container:
user_repo = await request_container.get(UserRepository) user_repo = await request_container.get(UserRepository)
@@ -6,8 +6,7 @@ from aiogram_dialog.widgets.text import Const
from dishka import FromDishka from dishka import FromDishka
from dishka.integrations.aiogram_dialog import inject from dishka.integrations.aiogram_dialog import inject
from trudex.application.bot.admin_dialogs.states import (AdminBroadcastSG, from trudex.application.bot.admin_dialogs.states import AdminBroadcastSG, AdminMenuSG
AdminMenuSG)
from trudex.infrastructure.database.dao.user import UserDAO from trudex.infrastructure.database.dao.user import UserDAO
from trudex.infrastructure.utils.broadcast import broadcast_message from trudex.infrastructure.utils.broadcast import broadcast_message
@@ -3,14 +3,12 @@ from datetime import date, datetime, time
from aiogram.types import CallbackQuery, ContentType, Message from aiogram.types import CallbackQuery, ContentType, Message
from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog import Dialog, DialogManager, StartMode, Window
from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.input import MessageInput
from aiogram_dialog.widgets.kbd import (Button, Calendar, Cancel, Column, Row, from aiogram_dialog.widgets.kbd import Button, Calendar, Cancel, Column, Row, ScrollingGroup, Select
ScrollingGroup, Select)
from aiogram_dialog.widgets.text import Const, Format from aiogram_dialog.widgets.text import Const, Format
from dishka import FromDishka from dishka import FromDishka
from dishka.integrations.aiogram_dialog import inject from dishka.integrations.aiogram_dialog import inject
from trudex.application.bot.admin_dialogs.states import (AdminCreateTestSG, from trudex.application.bot.admin_dialogs.states import AdminCreateTestSG, AdminTestsSG
AdminTestsSG)
from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.group import GroupDAO
from trudex.infrastructure.database.dao.option import OptionDAO from trudex.infrastructure.database.dao.option import OptionDAO
from trudex.infrastructure.database.dao.question import QuestionDAO from trudex.infrastructure.database.dao.question import QuestionDAO
@@ -1,14 +1,12 @@
from aiogram.types import CallbackQuery, Message from aiogram.types import CallbackQuery, Message
from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog import Dialog, DialogManager, StartMode, Window
from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.input import MessageInput
from aiogram_dialog.widgets.kbd import (Button, Column, Row, ScrollingGroup, from aiogram_dialog.widgets.kbd import Button, Column, Row, ScrollingGroup, Select
Select)
from aiogram_dialog.widgets.text import Const, Format from aiogram_dialog.widgets.text import Const, Format
from dishka import FromDishka from dishka import FromDishka
from dishka.integrations.aiogram_dialog import inject from dishka.integrations.aiogram_dialog import inject
from trudex.application.bot.admin_dialogs.states import (AdminGroupsSG, from trudex.application.bot.admin_dialogs.states import AdminGroupsSG, AdminMenuSG
AdminMenuSG)
from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.group import GroupDAO
@@ -3,12 +3,8 @@ from aiogram_dialog import Dialog, DialogManager, StartMode, Window
from aiogram_dialog.widgets.kbd import Button, Column from aiogram_dialog.widgets.kbd import Button, Column
from aiogram_dialog.widgets.text import Const from aiogram_dialog.widgets.text import Const
from trudex.application.bot.admin_dialogs.states import (AdminBroadcastSG, from trudex.application.bot.admin_dialogs.states import (AdminBroadcastSG, AdminGroupsSG, AdminMenuSG,
AdminGroupsSG, AdminTemplatesSG, AdminTestsSG, AdminUsersSG)
AdminMenuSG,
AdminTemplatesSG,
AdminTestsSG,
AdminUsersSG)
async def on_tests_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: async def on_tests_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None:
@@ -10,13 +10,13 @@ from dishka import FromDishka
from dishka.integrations.aiogram_dialog import inject from dishka.integrations.aiogram_dialog import inject
from trudex.application.bot.admin_dialogs.states import AdminMenuSG, AdminTemplatesSG, AdminTestsSG 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.domain.test_parser import ParsedTest, TestParser
from trudex.infrastructure.database.dao.option import OptionDAO from trudex.infrastructure.database.dao.option import OptionDAO
from trudex.infrastructure.database.dao.question import QuestionDAO from trudex.infrastructure.database.dao.question import QuestionDAO
from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.dao.test import TestDAO
from trudex.infrastructure.database.repo.test import TestRepository from trudex.infrastructure.database.repo.test import TestRepository
TEMPLATES_INFO = ( TEMPLATES_INFO = (
"<b>📦 Шаблоны тестов</b>\n\n" "<b>📦 Шаблоны тестов</b>\n\n"
"Шаблоны позволяют экспортировать и импортировать тесты в формате JSON.\n\n" "Шаблоны позволяют экспортировать и импортировать тесты в формате JSON.\n\n"
@@ -227,7 +227,7 @@ async def on_test_selected_for_export(
"question_type": question.question_type, "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] correct_options = [o for o in options if o.is_correct]
if correct_options: if correct_options:
question_data["correct_answer"] = correct_options[0].text question_data["correct_answer"] = correct_options[0].text
@@ -6,15 +6,12 @@ from aiogram import Bot
from aiogram.types import BufferedInputFile, CallbackQuery, Message from aiogram.types import BufferedInputFile, CallbackQuery, Message
from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog import Dialog, DialogManager, StartMode, Window
from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.input import MessageInput
from aiogram_dialog.widgets.kbd import (Button, Calendar, Column, from aiogram_dialog.widgets.kbd import Button, Calendar, Column, ScrollingGroup, Select
ScrollingGroup, Select)
from aiogram_dialog.widgets.text import Const, Format from aiogram_dialog.widgets.text import Const, Format
from dishka import FromDishka from dishka import FromDishka
from dishka.integrations.aiogram_dialog import inject from dishka.integrations.aiogram_dialog import inject
from trudex.application.bot.admin_dialogs.states import (AdminMenuSG, from trudex.application.bot.admin_dialogs.states import AdminCreateTestSG, AdminMenuSG, AdminTestsSG
AdminTestsSG,
AdminCreateTestSG)
from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.group import GroupDAO
from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.dao.test import TestDAO
from trudex.infrastructure.database.repo.test import TestRepository from trudex.infrastructure.database.repo.test import TestRepository
@@ -1,14 +1,12 @@
from aiogram.types import CallbackQuery, Message from aiogram.types import CallbackQuery, Message
from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog import Dialog, DialogManager, StartMode, Window
from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.input import MessageInput
from aiogram_dialog.widgets.kbd import (Button, Column, ScrollingGroup, Select, from aiogram_dialog.widgets.kbd import Button, Column, ScrollingGroup, Select, SwitchTo
SwitchTo)
from aiogram_dialog.widgets.text import Const, Format from aiogram_dialog.widgets.text import Const, Format
from dishka import FromDishka from dishka import FromDishka
from dishka.integrations.aiogram_dialog import inject from dishka.integrations.aiogram_dialog import inject
from trudex.application.bot.admin_dialogs.states import (AdminMenuSG, from trudex.application.bot.admin_dialogs.states import AdminMenuSG, AdminUsersSG
AdminUsersSG)
from trudex.infrastructure.database.dao.user import UserDAO from trudex.infrastructure.database.dao.user import UserDAO
@@ -6,8 +6,7 @@ from aiogram_dialog.widgets.text import Const
from dishka import FromDishka from dishka import FromDishka
from dishka.integrations.aiogram_dialog import inject from dishka.integrations.aiogram_dialog import inject
from trudex.application.bot.creator_dialogs.states import (CreatorBroadcastSG, from trudex.application.bot.creator_dialogs.states import CreatorBroadcastSG, CreatorMenuSG
CreatorMenuSG)
from trudex.infrastructure.database.dao.user import UserDAO from trudex.infrastructure.database.dao.user import UserDAO
from trudex.infrastructure.utils.broadcast import broadcast_message from trudex.infrastructure.utils.broadcast import broadcast_message
@@ -3,14 +3,12 @@ from datetime import date, datetime, time
from aiogram.types import CallbackQuery, ContentType, Message from aiogram.types import CallbackQuery, ContentType, Message
from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog import Dialog, DialogManager, StartMode, Window
from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.input import MessageInput
from aiogram_dialog.widgets.kbd import (Button, Calendar, Cancel, Column, Row, from aiogram_dialog.widgets.kbd import Button, Calendar, Cancel, Column, Row, ScrollingGroup, Select
ScrollingGroup, Select)
from aiogram_dialog.widgets.text import Const, Format from aiogram_dialog.widgets.text import Const, Format
from dishka import FromDishka from dishka import FromDishka
from dishka.integrations.aiogram_dialog import inject from dishka.integrations.aiogram_dialog import inject
from trudex.application.bot.creator_dialogs.states import (CreateTestSG, from trudex.application.bot.creator_dialogs.states import CreateTestSG, CreatorTestsSG
CreatorTestsSG)
from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.group import GroupDAO
from trudex.infrastructure.database.dao.option import OptionDAO from trudex.infrastructure.database.dao.option import OptionDAO
from trudex.infrastructure.database.dao.question import QuestionDAO from trudex.infrastructure.database.dao.question import QuestionDAO
@@ -1,14 +1,12 @@
from aiogram.types import CallbackQuery, Message from aiogram.types import CallbackQuery, Message
from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog import Dialog, DialogManager, StartMode, Window
from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.input import MessageInput
from aiogram_dialog.widgets.kbd import (Button, Column, Row, ScrollingGroup, from aiogram_dialog.widgets.kbd import Button, Column, Row, ScrollingGroup, Select
Select)
from aiogram_dialog.widgets.text import Const, Format from aiogram_dialog.widgets.text import Const, Format
from dishka import FromDishka from dishka import FromDishka
from dishka.integrations.aiogram_dialog import inject from dishka.integrations.aiogram_dialog import inject
from trudex.application.bot.creator_dialogs.states import (CreatorGroupsSG, from trudex.application.bot.creator_dialogs.states import CreatorGroupsSG, CreatorMenuSG
CreatorMenuSG)
from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.group import GroupDAO
@@ -3,12 +3,8 @@ from aiogram_dialog import Dialog, DialogManager, StartMode, Window
from aiogram_dialog.widgets.kbd import Button, Column from aiogram_dialog.widgets.kbd import Button, Column
from aiogram_dialog.widgets.text import Const from aiogram_dialog.widgets.text import Const
from trudex.application.bot.creator_dialogs.states import (CreatorBroadcastSG, from trudex.application.bot.creator_dialogs.states import (CreatorBroadcastSG, CreatorGroupsSG, CreatorMenuSG,
CreatorGroupsSG, CreatorTemplatesSG, CreatorTestsSG, CreatorUsersSG)
CreatorMenuSG,
CreatorTemplatesSG,
CreatorTestsSG,
CreatorUsersSG)
async def on_tests_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: async def on_tests_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None:
@@ -10,13 +10,13 @@ from dishka import FromDishka
from dishka.integrations.aiogram_dialog import inject from dishka.integrations.aiogram_dialog import inject
from trudex.application.bot.creator_dialogs.states import CreatorMenuSG, CreatorTemplatesSG, CreatorTestsSG 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.domain.test_parser import ParsedTest, TestParser
from trudex.infrastructure.database.dao.option import OptionDAO from trudex.infrastructure.database.dao.option import OptionDAO
from trudex.infrastructure.database.dao.question import QuestionDAO from trudex.infrastructure.database.dao.question import QuestionDAO
from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.dao.test import TestDAO
from trudex.infrastructure.database.repo.test import TestRepository from trudex.infrastructure.database.repo.test import TestRepository
TEMPLATES_INFO = ( TEMPLATES_INFO = (
"<b>📦 Шаблоны тестов</b>\n\n" "<b>📦 Шаблоны тестов</b>\n\n"
"Шаблоны позволяют экспортировать и импортировать тесты в формате JSON.\n\n" "Шаблоны позволяют экспортировать и импортировать тесты в формате JSON.\n\n"
@@ -227,7 +227,7 @@ async def on_test_selected_for_export(
"question_type": question.question_type, "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] correct_options = [o for o in options if o.is_correct]
if correct_options: if correct_options:
question_data["correct_answer"] = correct_options[0].text question_data["correct_answer"] = correct_options[0].text
@@ -8,16 +8,13 @@ from aiogram.types import BufferedInputFile, CallbackQuery, Message
from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog import Dialog, DialogManager, StartMode, Window
from aiogram_dialog.api.entities import MediaAttachment from aiogram_dialog.api.entities import MediaAttachment
from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.input import MessageInput
from aiogram_dialog.widgets.kbd import (Button, Calendar, Column, Row, from aiogram_dialog.widgets.kbd import Button, Calendar, Column, Row, ScrollingGroup, Select
ScrollingGroup, Select)
from aiogram_dialog.widgets.media import DynamicMedia from aiogram_dialog.widgets.media import DynamicMedia
from aiogram_dialog.widgets.text import Const, Format from aiogram_dialog.widgets.text import Const, Format
from dishka import FromDishka from dishka import FromDishka
from dishka.integrations.aiogram_dialog import inject from dishka.integrations.aiogram_dialog import inject
from trudex.application.bot.creator_dialogs.states import (CreateTestSG, from trudex.application.bot.creator_dialogs.states import CreateTestSG, CreatorMenuSG, CreatorTestsSG
CreatorMenuSG,
CreatorTestsSG)
from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.group import GroupDAO
from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.dao.test import TestDAO
from trudex.infrastructure.database.repo.test import TestRepository from trudex.infrastructure.database.repo.test import TestRepository
@@ -4,14 +4,12 @@ from aiogram import Bot
from aiogram.types import CallbackQuery, Message from aiogram.types import CallbackQuery, Message
from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog import Dialog, DialogManager, StartMode, Window
from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.input import MessageInput
from aiogram_dialog.widgets.kbd import (Button, Column, Row, ScrollingGroup, from aiogram_dialog.widgets.kbd import Button, Column, Row, ScrollingGroup, Select, SwitchTo
Select, SwitchTo)
from aiogram_dialog.widgets.text import Const, Format from aiogram_dialog.widgets.text import Const, Format
from dishka import FromDishka from dishka import FromDishka
from dishka.integrations.aiogram_dialog import inject from dishka.integrations.aiogram_dialog import inject
from trudex.application.bot.creator_dialogs.states import (CreatorMenuSG, from trudex.application.bot.creator_dialogs.states import CreatorMenuSG, CreatorUsersSG
CreatorUsersSG)
from trudex.infrastructure.database.dao.user import UserDAO from trudex.infrastructure.database.dao.user import UserDAO
from trudex.infrastructure.database.repo.user import UserRepository from trudex.infrastructure.database.repo.user import UserRepository
from trudex.infrastructure.utils.bot_commands import setup_bot_commands from trudex.infrastructure.utils.bot_commands import setup_bot_commands
+34 -6
View File
@@ -1,5 +1,7 @@
import logging
from aiogram import Router 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.types import ErrorEvent, Message
from aiogram_dialog import DialogManager, StartMode from aiogram_dialog import DialogManager, StartMode
from aiogram_dialog.api.exceptions import OutdatedIntent, UnknownIntent 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.admin_dialogs.states import AdminMenuSG
from trudex.application.bot.creator_dialogs.states import CreatorMenuSG from trudex.application.bot.creator_dialogs.states import CreatorMenuSG
from trudex.application.bot.user_dialogs.states import ( from trudex.application.bot.user_dialogs.states import UserDeeplinkSG, UserMenuSG, UserRegistrationSG
UserDeeplinkSG,
UserMenuSG,
UserRegistrationSG,
)
from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.group import GroupDAO
from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.dao.test import TestDAO
from trudex.infrastructure.database.dao.user import UserDAO 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 from trudex.infrastructure.utils.timezone import now_msk_naive
router = Router() router = Router()
logger = logging.getLogger(__name__)
async def ensure_user_registered( async def ensure_user_registered(
@@ -115,6 +114,13 @@ async def start_with_deeplink(
assert message.from_user is not None assert message.from_user is not None
deeplink = command.args 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: if not deeplink:
await start_handler(message, user_dao, group_dao, dialog_manager) await start_handler(message, user_dao, group_dao, dialog_manager)
return return
@@ -122,6 +128,7 @@ async def start_with_deeplink(
try: try:
test_id = decode_id(deeplink, config.security.encode_key) test_id = decode_id(deeplink, config.security.encode_key)
except (ValueError, IndexError): except (ValueError, IndexError):
logger.warning("Invalid deeplink: user_id=%d, deeplink=%s", message.from_user.id, deeplink)
await message.answer("❌ Неверная ссылка на тест") await message.answer("❌ Неверная ссылка на тест")
await start_handler(message, user_dao, group_dao, dialog_manager) await start_handler(message, user_dao, group_dao, dialog_manager)
return return
@@ -138,6 +145,12 @@ async def start_with_deeplink(
) )
if not is_valid: 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( await dialog_manager.start(
UserDeeplinkSG.test_preview, UserDeeplinkSG.test_preview,
mode=StartMode.RESET_STACK, mode=StartMode.RESET_STACK,
@@ -145,6 +158,7 @@ async def start_with_deeplink(
) )
return return
logger.info("User starting test via deeplink: user_id=%d, test_id=%d", message.from_user.id, test_id)
await dialog_manager.start( await dialog_manager.start(
UserDeeplinkSG.test_preview, UserDeeplinkSG.test_preview,
mode=StartMode.RESET_STACK, mode=StartMode.RESET_STACK,
@@ -159,6 +173,13 @@ async def start_handler(
user_dao: FromDishka[UserDAO], user_dao: FromDishka[UserDAO],
group_dao: FromDishka[GroupDAO], group_dao: FromDishka[GroupDAO],
) -> None: ) -> 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( is_registered = await ensure_user_registered(
user_dao, group_dao, message, dialog_manager user_dao, group_dao, message, dialog_manager
) )
@@ -169,15 +190,22 @@ async def start_handler(
@router.message(Command("admin")) @router.message(Command("admin"))
async def admin_command(_message: Message, dialog_manager: DialogManager) -> None: 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) await dialog_manager.start(AdminMenuSG.main, mode=StartMode.RESET_STACK)
@router.message(Command("creator")) @router.message(Command("creator"))
async def creator_command(_message: Message, dialog_manager: DialogManager) -> None: 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) await dialog_manager.start(CreatorMenuSG.main, mode=StartMode.RESET_STACK)
@router.error() @router.error()
async def dialog_error_handler(event: ErrorEvent, dialog_manager: DialogManager) -> None: async def dialog_error_handler(event: ErrorEvent, dialog_manager: DialogManager) -> None:
if isinstance(event.exception, (UnknownIntent, OutdatedIntent)): 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) await dialog_manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK)
else:
logger.exception("Unhandled error in dialog: %s", event.exception)
@@ -1,6 +1,6 @@
import asyncio import asyncio
import functools import functools
from datetime import timedelta from datetime import datetime, timedelta
from aiogram import Bot from aiogram import Bot
from aiogram.types import BufferedInputFile, CallbackQuery, Message 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.qr_generator import generate_qr_bytes
from trudex.infrastructure.utils.test_id_to_hash import encode_id 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 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: def can_edit_field(updated_at: datetime | None) -> bool:
@@ -6,11 +6,7 @@ from aiogram_dialog.widgets.text import Const, Format
from dishka import FromDishka from dishka import FromDishka
from dishka.integrations.aiogram_dialog import inject from dishka.integrations.aiogram_dialog import inject
from trudex.application.bot.user_dialogs.states import ( from trudex.application.bot.user_dialogs.states import UserDeeplinkSG, UserMenuSG, UserRegistrationSG
UserDeeplinkSG,
UserMenuSG,
UserRegistrationSG,
)
from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.group import GroupDAO
from trudex.infrastructure.database.dao.user import UserDAO from trudex.infrastructure.database.dao.user import UserDAO
@@ -7,9 +7,9 @@ from dishka import FromDishka
from dishka.integrations.aiogram_dialog import inject from dishka.integrations.aiogram_dialog import inject
from trudex.application.bot.user_dialogs.states import UserMenuSG, UserTestSG 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.test import TestDAO
from trudex.infrastructure.database.dao.user_answer import UserAnswerDAO 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 import TestRepository
from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository
from trudex.infrastructure.utils.timezone import now_msk_naive from trudex.infrastructure.utils.timezone import now_msk_naive
+8 -1
View File
@@ -1,5 +1,12 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from enum import Enum
class QuestionType(str, Enum):
SINGLE = "single"
MULTIPLE = "multiple"
INPUT = "input"
@dataclass @dataclass
@@ -46,7 +53,7 @@ class Question:
test_id: int test_id: int
text: str text: str
position: int = 0 position: int = 0
question_type: str = "single" question_type: QuestionType = QuestionType.SINGLE
tg_file_id: str | None = None tg_file_id: str | None = None
+1 -2
View File
@@ -1,5 +1,4 @@
from sqlalchemy.ext.asyncio import (AsyncSession, async_sessionmaker, from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
create_async_engine)
def new_session_maker(db_url: str) -> async_sessionmaker[AsyncSession]: def new_session_maker(db_url: str) -> async_sessionmaker[AsyncSession]:
@@ -2,8 +2,9 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from trudex.domain.schemas import Question as DomainQuestion 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.dto.question import QuestionDTO
from trudex.infrastructure.database.models import Question, QuestionType from trudex.infrastructure.database.models import Question
class QuestionDAO: class QuestionDAO:
@@ -27,14 +28,16 @@ class QuestionDAO:
test_id: int, test_id: int,
text: str, text: str,
position: int = 0, position: int = 0,
question_type: str = "single", question_type: str | QuestionType = QuestionType.SINGLE,
tg_file_id: str | None = None, tg_file_id: str | None = None,
) -> DomainQuestion: ) -> DomainQuestion:
if isinstance(question_type, str):
question_type = QuestionType(question_type)
question = Question( question = Question(
test_id=test_id, test_id=test_id,
text=text, text=text,
position=position, position=position,
question_type=QuestionType(question_type), question_type=question_type,
tg_file_id=tg_file_id, tg_file_id=tg_file_id,
) )
self.session.add(question) self.session.add(question)
@@ -47,7 +50,7 @@ class QuestionDAO:
question_id: int, question_id: int,
text: str | None = None, text: str | None = None,
position: int | None = None, position: int | None = None,
question_type: str | None = None, question_type: str | QuestionType | None = None,
tg_file_id: str | None = None, tg_file_id: str | None = None,
) -> DomainQuestion | None: ) -> DomainQuestion | None:
result = await self.session.execute( result = await self.session.execute(
@@ -62,7 +65,9 @@ class QuestionDAO:
if position is not None: if position is not None:
question.position = position question.position = position
if question_type is not None: 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: if tg_file_id is not None:
question.tg_file_id = tg_file_id question.tg_file_id = tg_file_id
+46 -16
View File
@@ -1,4 +1,5 @@
from datetime import datetime from datetime import datetime
from typing import NotRequired, TypedDict, Unpack
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession 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 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: class TestDAO:
def __init__(self, session: AsyncSession) -> None: def __init__(self, session: AsyncSession) -> None:
self.session: AsyncSession = session self.session: AsyncSession = session
@@ -26,6 +46,16 @@ class TestDAO:
models = list(result.scalars().all()) models = list(result.scalars().all())
return [TestDTO(model).to_domain() for model in models] 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( async def create(
self, self,
title: str, title: str,
@@ -55,14 +85,14 @@ class TestDAO:
async def update( async def update(
self, self,
test_id: int, test_id: int,
title: str | None = None, title: str | _UNSET = UNSET,
description: str | None = None, description: str | None | _UNSET = UNSET,
for_group: int | None = None, for_group: int | None | _UNSET = UNSET,
password: str | None = None, password: str | None | _UNSET = UNSET,
expires_at: datetime | None = None, expires_at: datetime | None | _UNSET = UNSET,
attempts: int | None = None, attempts: int | None | _UNSET = UNSET,
is_active: bool | None = None, is_active: bool | _UNSET = UNSET,
are_results_viewable: bool | None = None, are_results_viewable: bool | _UNSET = UNSET,
) -> DomainTest | None: ) -> DomainTest | None:
result = await self.session.execute( result = await self.session.execute(
select(Test).where(Test.id == test_id) select(Test).where(Test.id == test_id)
@@ -71,21 +101,21 @@ class TestDAO:
if not test: if not test:
return None return None
if title is not None: if not isinstance(title, _UNSET):
test.title = title test.title = title
if description is not None: if not isinstance(description, _UNSET):
test.description = description test.description = description
if for_group is not None: if not isinstance(for_group, _UNSET):
test.for_group = for_group test.for_group = for_group
if password is not None: if not isinstance(password, _UNSET):
test.password = password test.password = password
if expires_at is not None: if not isinstance(expires_at, _UNSET):
test.expires_at = expires_at test.expires_at = expires_at
if attempts is not None: if not isinstance(attempts, _UNSET):
test.attempts = attempts test.attempts = attempts
if is_active is not None: if not isinstance(is_active, _UNSET):
test.is_active = is_active 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 test.are_results_viewable = are_results_viewable
await self.session.flush() await self.session.flush()
+36 -31
View File
@@ -8,6 +8,14 @@ from trudex.infrastructure.database.dto.user import UserDTO
from trudex.infrastructure.database.models import User from trudex.infrastructure.database.models import User
class _UNSET:
"""Sentinel для различения None и "не передано"."""
pass
UNSET = _UNSET()
class UserDAO: class UserDAO:
def __init__(self, session: AsyncSession) -> None: def __init__(self, session: AsyncSession) -> None:
self.session: AsyncSession = session self.session: AsyncSession = session
@@ -53,14 +61,14 @@ class UserDAO:
async def update( async def update(
self, self,
user_id: int, user_id: int,
username: str | None = None, username: str | None | _UNSET = UNSET,
first_name: str | None = None, first_name: str | _UNSET = UNSET,
last_name: str | None = None, last_name: str | None | _UNSET = UNSET,
name: str | None = None, name: str | None | _UNSET = UNSET,
group: int | None = None, group: int | None | _UNSET = UNSET,
is_admin: bool | None = None, is_admin: bool | _UNSET = UNSET,
name_updated_at: datetime | None = None, name_updated_at: datetime | None | _UNSET = UNSET,
group_updated_at: datetime | None = None, group_updated_at: datetime | None | _UNSET = UNSET,
) -> DomainUser | None: ) -> DomainUser | None:
result = await self.session.execute( result = await self.session.execute(
select(User).where(User.id == user_id) select(User).where(User.id == user_id)
@@ -69,21 +77,21 @@ class UserDAO:
if not user: if not user:
return None return None
if username is not None: if not isinstance(username, _UNSET):
user.username = username user.username = username
if first_name is not None: if not isinstance(first_name, _UNSET):
user.first_name = first_name user.first_name = first_name
if last_name is not None: if not isinstance(last_name, _UNSET):
user.last_name = last_name user.last_name = last_name
if name is not None: if not isinstance(name, _UNSET):
user.name = name user.name = name
if group is not None: if not isinstance(group, _UNSET):
user.group = group user.group = group
if is_admin is not None: if not isinstance(is_admin, _UNSET):
user.is_admin = is_admin 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 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 user.group_updated_at = group_updated_at
await self.session.flush() await self.session.flush()
@@ -108,9 +116,9 @@ class UserDAO:
first_name: str, first_name: str,
username: str | None = None, username: str | None = None,
last_name: str | None = None, last_name: str | None = None,
name: str | None = None, name: str | None | _UNSET = UNSET,
group: int | None = None, group: int | None | _UNSET = UNSET,
is_admin: bool | None = None, is_admin: bool | _UNSET = UNSET,
) -> DomainUser: ) -> DomainUser:
result = await self.session.execute( result = await self.session.execute(
select(User).where(User.id == user_id) select(User).where(User.id == user_id)
@@ -118,17 +126,14 @@ class UserDAO:
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if user: if user:
if username is not None: user.username = username
user.username = username user.first_name = first_name
if first_name is not None: user.last_name = last_name
user.first_name = first_name if not isinstance(name, _UNSET):
if last_name is not None:
user.last_name = last_name
if name is not None:
user.name = name user.name = name
if group is not None: if not isinstance(group, _UNSET):
user.group = group user.group = group
if is_admin is not None: if not isinstance(is_admin, _UNSET):
user.is_admin = is_admin user.is_admin = is_admin
await self.session.flush() await self.session.flush()
await self.session.refresh(user) await self.session.refresh(user)
@@ -139,7 +144,7 @@ class UserDAO:
username=username, username=username,
first_name=first_name, first_name=first_name,
last_name=last_name, last_name=last_name,
name=name, name=name if not isinstance(name, _UNSET) else None,
group=group, group=group if not isinstance(group, _UNSET) else None,
is_admin=is_admin or False, is_admin=is_admin if not isinstance(is_admin, _UNSET) else False,
) )
@@ -12,6 +12,6 @@ class QuestionDTO:
test_id=self.model.test_id, test_id=self.model.test_id,
text=self.model.text, text=self.model.text,
position=self.model.position, 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, tg_file_id=self.model.tg_file_id,
) )
@@ -1,6 +1,5 @@
from trudex.domain.schemas import TestAttempt as DomainTestAttempt from trudex.domain.schemas import TestAttempt as DomainTestAttempt
from trudex.infrastructure.database.models import \ from trudex.infrastructure.database.models import TestAttempt as TestAttemptModel
TestAttempt as TestAttemptModel
class TestAttemptDTO: class TestAttemptDTO:
+3 -12
View File
@@ -1,11 +1,11 @@
from datetime import datetime from datetime import datetime
from enum import Enum
from typing import final from typing import final
from sqlalchemy import (BigInteger, CheckConstraint, ForeignKey, Integer, from sqlalchemy import BigInteger, CheckConstraint, ForeignKey, Integer, String, Text, func
String, Text, func)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from trudex.domain.schemas import QuestionType
class Base(DeclarativeBase): class Base(DeclarativeBase):
pass pass
@@ -40,15 +40,6 @@ class Group(Base):
__table_args__ = ( __table_args__ = (
CheckConstraint("number >= 1000 AND number <= 9999", name="check_group_number"), 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 @final
@@ -1,6 +1,5 @@
from trudex.infrastructure.database.repo.test import TestRepository from trudex.infrastructure.database.repo.test import TestRepository
from trudex.infrastructure.database.repo.test_attempt import \ from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository
TestAttemptRepository
from trudex.infrastructure.database.repo.user import UserRepository from trudex.infrastructure.database.repo.user import UserRepository
__all__ = ["TestRepository", "TestAttemptRepository", "UserRepository"] __all__ = ["TestRepository", "TestAttemptRepository", "UserRepository"]
@@ -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.dao.user_answer import UserAnswerDAO
from trudex.infrastructure.database.dto.test_attempt import TestAttemptDTO from trudex.infrastructure.database.dto.test_attempt import TestAttemptDTO
from trudex.infrastructure.database.dto.user_answer import UserAnswerDTO from trudex.infrastructure.database.dto.user_answer import UserAnswerDTO
from trudex.infrastructure.database.models import \ from trudex.infrastructure.database.models import TestAttempt as TestAttemptModel
TestAttempt as TestAttemptModel
from trudex.infrastructure.database.models import UserAnswer as UserAnswerModel from trudex.infrastructure.database.models import UserAnswer as UserAnswerModel
from trudex.infrastructure.utils.timezone import now_msk_naive 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]]: async def get_most_difficult_questions(self, test_id: int, limit: int = 10) -> list[tuple[int, float]]:
from trudex.infrastructure.database.models import \ from trudex.infrastructure.database.models import Question as QuestionModel
Question as QuestionModel
result = await self.session.execute( result = await self.session.execute(
select( select(
+2 -3
View File
@@ -1,5 +1,5 @@
from collections.abc import AsyncIterable
import logging import logging
from collections.abc import AsyncIterable
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from dishka import AsyncContainer, Provider, Scope, provide 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 import UserDAO
from trudex.infrastructure.database.dao.user_answer import UserAnswerDAO from trudex.infrastructure.database.dao.user_answer import UserAnswerDAO
from trudex.infrastructure.database.repo.test import TestRepository from trudex.infrastructure.database.repo.test import TestRepository
from trudex.infrastructure.database.repo.test_attempt import \ from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository
TestAttemptRepository
from trudex.infrastructure.database.repo.user import UserRepository from trudex.infrastructure.database.repo.user import UserRepository
from trudex.infrastructure.scheduling.tasks import deactivate_expired_tests from trudex.infrastructure.scheduling.tasks import deactivate_expired_tests
from trudex.infrastructure.utils.config import Config from trudex.infrastructure.utils.config import Config
@@ -1,15 +1,19 @@
import logging
from dishka import AsyncContainer from dishka import AsyncContainer
from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.dao.test import TestDAO
from trudex.infrastructure.utils.timezone import now_msk_naive 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: async with container() as request_container:
test_dao = await request_container.get(TestDAO) 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: for test in expired_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)
await test_dao.update(test.id, is_active=False) logger.info("Деактивирован истёкший тест: id=%d, title=%s", test.id, test.title)
@@ -1,6 +1,5 @@
from aiogram import Bot from aiogram import Bot
from aiogram.types import (BotCommand, BotCommandScopeAllPrivateChats, from aiogram.types import BotCommand, BotCommandScopeAllPrivateChats, BotCommandScopeChat
BotCommandScopeChat)
from trudex.infrastructure.database.repo.user import UserRepository from trudex.infrastructure.database.repo.user import UserRepository
from trudex.infrastructure.utils.config import Config from trudex.infrastructure.utils.config import Config
+9 -3
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import logging
from dataclasses import dataclass from dataclasses import dataclass
from aiogram import Bot from aiogram import Bot
@@ -6,6 +7,8 @@ from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from trudex.infrastructure.database.dao.user import UserDAO from trudex.infrastructure.database.dao.user import UserDAO
logger = logging.getLogger(__name__)
@dataclass @dataclass
class BroadcastStats: class BroadcastStats:
@@ -19,17 +22,20 @@ async def broadcast_message(bot: Bot, message_id: int, chat_id: int, user_dao: U
success = 0 success = 0
failed = 0 failed = 0
logger.info("Starting broadcast: message_id=%d, total_users=%d", message_id, len(users))
for user in users: for user in users:
try: try:
await bot.copy_message(chat_id=user.id, from_chat_id=chat_id, message_id=message_id) await bot.copy_message(chat_id=user.id, from_chat_id=chat_id, message_id=message_id)
success += 1 success += 1
except TelegramForbiddenError: except TelegramForbiddenError:
logger.debug("Broadcast failed (forbidden): user_id=%d", user.id)
failed += 1 failed += 1
except TelegramBadRequest: except TelegramBadRequest as e:
failed += 1 logger.debug("Broadcast failed (bad request): user_id=%d, error=%s", user.id, e)
except Exception:
failed += 1 failed += 1
await asyncio.sleep(0.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)) return BroadcastStats(success=success, failed=failed, total=len(users))
@@ -1,8 +1,7 @@
import hmac
import hashlib import hashlib
import hmac
import string import string
ALPHABET = string.ascii_letters ALPHABET = string.ascii_letters
def _feistel_round(val: int, key: bytes, rounds: int) -> int: def _feistel_round(val: int, key: bytes, rounds: int) -> int: