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
+16 -34
View File
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -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:
@@ -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 = (
"<b>📦 Шаблоны тестов</b>\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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
@@ -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 = (
"<b>📦 Шаблоны тестов</b>\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
@@ -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
@@ -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
+34 -6
View File
@@ -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)
@@ -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:
@@ -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
@@ -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
+8 -1
View File
@@ -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
+1 -2
View File
@@ -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]:
@@ -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
+46 -16
View File
@@ -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()
+36 -31
View File
@@ -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,
)
@@ -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,
)
@@ -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:
+3 -12
View File
@@ -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
@@ -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"]
@@ -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(
+2 -3
View File
@@ -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
@@ -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)
@@ -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
+9 -3
View File
@@ -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))
@@ -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: