From 53b846009b7c80ceb6d416f5436a00b6dbbb0f74 Mon Sep 17 00:00:00 2001 From: kolo Date: Sun, 4 Jan 2026 01:01:07 +0300 Subject: [PATCH] commit --- .../application/bot/admin_dialogs/tests.py | 15 ++-- .../bot/creator_dialogs/create_test.py | 8 ++- .../application/bot/creator_dialogs/tests.py | 15 ++-- src/trudex/application/bot/handlers.py | 5 +- .../application/bot/user_dialogs/main_menu.py | 70 +++++++++++++------ .../application/bot/user_dialogs/take_test.py | 5 +- .../infrastructure/database/dao/user.py | 4 +- .../database/repo/test_attempt.py | 4 +- src/trudex/infrastructure/scheduling/tasks.py | 6 +- src/trudex/infrastructure/utils/timezone.py | 16 +++++ 10 files changed, 97 insertions(+), 51 deletions(-) create mode 100644 src/trudex/infrastructure/utils/timezone.py diff --git a/src/trudex/application/bot/admin_dialogs/tests.py b/src/trudex/application/bot/admin_dialogs/tests.py index 043e3c9..f490495 100644 --- a/src/trudex/application/bot/admin_dialogs/tests.py +++ b/src/trudex/application/bot/admin_dialogs/tests.py @@ -1,6 +1,6 @@ import asyncio import functools -from datetime import date, datetime +from datetime import date, datetime, time from aiogram import Bot from aiogram.types import BufferedInputFile, CallbackQuery, Message @@ -22,6 +22,7 @@ from trudex.infrastructure.database.repo.test_attempt import TestAttemptReposito 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 MSK_TZ, to_msk @inject @@ -66,7 +67,7 @@ async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[T status = "🟢 Активен" if test.is_active else "🔴 Деактивирован" password_str = f"🔒 {test.password}" if test.password else "🔓 Без пароля" attempts_str = f"🔄 {test.attempts}" if test.attempts else "♾️ Без ограничений" - expires_str = f"📅 {test.expires_at.strftime('%d.%m.%Y %H:%M')}" if test.expires_at else "📅 Без срока" + expires_str = f"📅 {to_msk(test.expires_at).strftime('%d.%m.%Y %H:%M')}" if test.expires_at else "📅 Без срока" group_str = f"🎓 Группа {test.for_group}" if test.for_group else "👥 Для всех" test_info = ( @@ -79,7 +80,7 @@ async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[T f"Попытки: {attempts_str}\n" f"Срок: {expires_str}\n" f"Группа: {group_str}\n\n" - f"Создан: {test.created_at.strftime('%d.%m.%Y %H:%M') if test.created_at else '—'}" + f"Создан: {to_msk(test.created_at).strftime('%d.%m.%Y %H:%M') if test.created_at else '—'}" ) button_text = "🔴 Деактивировать" if test.is_active else "🟢 Активировать" @@ -131,7 +132,8 @@ async def get_statistics_data( results = [] for attempt, user_name in attempts_with_users: status = "✅" if attempt.is_passed else "❌" - date_str = attempt.finished_at.strftime("%d.%m.%Y %H:%M") if attempt.finished_at else "" + finished_at_msk = to_msk(attempt.finished_at) + date_str = finished_at_msk.strftime("%d.%m.%Y %H:%M") if finished_at_msk else "" results.append((f"{status} {user_name} — {attempt.score}% ({date_str})", attempt.id)) return { @@ -167,7 +169,8 @@ async def get_attempt_detail( return {"attempt_info": "❌ Результат не найден"} status = "✅ Пройден" if attempt.is_passed else "❌ Не пройден" - date_str = attempt.finished_at.strftime("%d.%m.%Y %H:%M") if attempt.finished_at else "—" + finished_at_msk = to_msk(attempt.finished_at) + date_str = finished_at_msk.strftime("%d.%m.%Y %H:%M") if finished_at_msk else "—" lines = [ f"📊 Результат прохождения\n", @@ -376,7 +379,7 @@ async def on_date_selected_for_test(_callback, _widget, manager: DialogManager, await _callback.answer("❌ Тест не найден") return - expires_at = datetime.combine(selected_date, datetime.min.time()) + expires_at = datetime.combine(selected_date, time.min, tzinfo=MSK_TZ) await test_dao.update(test_id, expires_at=expires_at) await _callback.answer("✅ Срок действия обновлен") await manager.switch_to(AdminTestsSG.test_detail) diff --git a/src/trudex/application/bot/creator_dialogs/create_test.py b/src/trudex/application/bot/creator_dialogs/create_test.py index 30de6ef..5c89ef8 100644 --- a/src/trudex/application/bot/creator_dialogs/create_test.py +++ b/src/trudex/application/bot/creator_dialogs/create_test.py @@ -1,4 +1,4 @@ -from datetime import date, datetime +from datetime import date, datetime, time from aiogram.types import CallbackQuery, ContentType, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window @@ -16,6 +16,7 @@ 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 +from trudex.infrastructure.utils.timezone import MSK_TZ, to_msk async def on_title_input(message: Message, _widget: MessageInput, manager: DialogManager): @@ -110,7 +111,7 @@ async def on_skip_attempts(_callback: CallbackQuery, _button: Button, manager: D async def on_date_selected(_callback, _widget, manager: DialogManager, selected_date: date): - manager.dialog_data["expires_at"] = datetime.combine(selected_date, datetime.min.time()) + manager.dialog_data["expires_at"] = datetime.combine(selected_date, time.min, tzinfo=MSK_TZ) await manager.switch_to(CreateTestSG.input_for_group) @@ -148,7 +149,8 @@ async def get_test_info(dialog_manager: DialogManager, **_kwargs): password_str = f"🔒 {password}" if password else "Без пароля" attempts_str = f"🔄 {attempts}" if attempts else "♾️ Без ограничений" - expires_str = expires_at.strftime("%d.%m.%Y") if expires_at else "Без срока" + expires_at_msk = to_msk(expires_at) + expires_str = expires_at_msk.strftime("%d.%m.%Y") if expires_at_msk else "Без срока" group_str = str(for_group) if for_group else "Для всех" return { diff --git a/src/trudex/application/bot/creator_dialogs/tests.py b/src/trudex/application/bot/creator_dialogs/tests.py index a522a85..71465d1 100644 --- a/src/trudex/application/bot/creator_dialogs/tests.py +++ b/src/trudex/application/bot/creator_dialogs/tests.py @@ -1,6 +1,6 @@ import asyncio import functools -from datetime import date, datetime +from datetime import date, datetime, time from aiogram import Bot from aiogram.enums import ContentType @@ -25,6 +25,7 @@ from trudex.infrastructure.database.repo.test_attempt import TestAttemptReposito 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 MSK_TZ, to_msk @inject @@ -69,7 +70,7 @@ async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[T status = "🟢 Активен" if test.is_active else "🔴 Деактивирован" password_str = f"🔒 {test.password}" if test.password else "🔓 Без пароля" attempts_str = f"🔄 {test.attempts}" if test.attempts else "♾️ Без ограничений" - expires_str = f"📅 {test.expires_at.strftime('%d.%m.%Y %H:%M')}" if test.expires_at else "📅 Без срока" + expires_str = f"📅 {to_msk(test.expires_at).strftime('%d.%m.%Y %H:%M')}" if test.expires_at else "📅 Без срока" group_str = f"🎓 Группа {test.for_group}" if test.for_group else "👥 Для всех" test_info = ( @@ -82,7 +83,7 @@ async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[T f"Попытки: {attempts_str}\n" f"Срок: {expires_str}\n" f"Группа: {group_str}\n\n" - f"Создан: {test.created_at.strftime('%d.%m.%Y %H:%M') if test.created_at else '—'}" + f"Создан: {to_msk(test.created_at).strftime('%d.%m.%Y %H:%M') if test.created_at else '—'}" ) button_text = "🔴 Деактивировать" if test.is_active else "🟢 Активировать" @@ -134,7 +135,8 @@ async def get_statistics_data( results = [] for attempt, user_name in attempts_with_users: status = "✅" if attempt.is_passed else "❌" - date_str = attempt.finished_at.strftime("%d.%m.%Y %H:%M") if attempt.finished_at else "" + finished_at_msk = to_msk(attempt.finished_at) + date_str = finished_at_msk.strftime("%d.%m.%Y %H:%M") if finished_at_msk else "" results.append((f"{status} {user_name} — {attempt.score}% ({date_str})", attempt.id)) return { @@ -170,7 +172,8 @@ async def get_attempt_detail( return {"attempt_info": "❌ Результат не найден"} status = "✅ Пройден" if attempt.is_passed else "❌ Не пройден" - date_str = attempt.finished_at.strftime("%d.%m.%Y %H:%M") if attempt.finished_at else "—" + finished_at_msk = to_msk(attempt.finished_at) + date_str = finished_at_msk.strftime("%d.%m.%Y %H:%M") if finished_at_msk else "—" lines = [ f"📊 Результат прохождения\n", @@ -380,7 +383,7 @@ async def on_date_selected_for_test(_callback, _widget, manager: DialogManager, await _callback.answer("❌ Тест не найден") return - expires_at = datetime.combine(selected_date, datetime.min.time()) + expires_at = datetime.combine(selected_date, time.min, tzinfo=MSK_TZ) await test_dao.update(test_id, expires_at=expires_at) await _callback.answer("✅ Срок действия обновлен") await manager.switch_to(CreatorTestsSG.test_detail) diff --git a/src/trudex/application/bot/handlers.py b/src/trudex/application/bot/handlers.py index 46b9fc1..57fad4d 100644 --- a/src/trudex/application/bot/handlers.py +++ b/src/trudex/application/bot/handlers.py @@ -1,5 +1,3 @@ -from datetime import datetime, timezone - from aiogram import Router from aiogram.filters import Command, CommandStart, CommandObject from aiogram.types import ErrorEvent, Message @@ -19,6 +17,7 @@ from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.dao.user import UserDAO from trudex.infrastructure.utils.config import Config from trudex.infrastructure.utils.test_id_to_hash import decode_id +from trudex.infrastructure.utils.timezone import now_msk router = Router() @@ -93,7 +92,7 @@ async def validate_deeplink_test( if not test.is_active: return False, "❌ Тест деактивирован" - if test.expires_at and test.expires_at < datetime.now(timezone.utc): + if test.expires_at and test.expires_at < now_msk(): return False, "❌ Срок действия теста истек" user = await user_dao.get_by_id(user_id) diff --git a/src/trudex/application/bot/user_dialogs/main_menu.py b/src/trudex/application/bot/user_dialogs/main_menu.py index f37af0b..b695639 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 datetime, timedelta, timezone +from datetime import timedelta from aiogram import Bot from aiogram.types import BufferedInputFile, CallbackQuery, Message @@ -20,6 +20,25 @@ from trudex.infrastructure.database.repo.test_attempt import TestAttemptReposito 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, to_msk +from datetime import datetime + + +def can_edit_field(updated_at: datetime | None) -> bool: + if updated_at is None: + return True + updated_at_msk = to_msk(updated_at) + assert updated_at_msk is not None + return now_msk() - updated_at_msk >= timedelta(hours=24) + + +def get_remaining_time(updated_at: datetime) -> str: + updated_at_msk = to_msk(updated_at) + assert updated_at_msk is not None + remaining = timedelta(hours=24) - (now_msk() - updated_at_msk) + hours = int(remaining.total_seconds() // 3600) + minutes = int((remaining.total_seconds() % 3600) // 60) + return f"{hours}ч {minutes}м" @inject @@ -57,19 +76,6 @@ async def get_user_data( return {"user_info": user_info} -def can_edit_field(updated_at: datetime | None) -> bool: - if updated_at is None: - return True - return datetime.now(timezone.utc) - updated_at >= timedelta(hours=24) - - -def get_remaining_time(updated_at: datetime) -> str: - remaining = timedelta(hours=24) - (datetime.now(timezone.utc) - updated_at) - hours = int(remaining.total_seconds() // 3600) - minutes = int((remaining.total_seconds() % 3600) // 60) - return f"{hours}ч {minutes}м" - - @inject async def on_edit_name_clicked( _callback: CallbackQuery, @@ -79,6 +85,7 @@ async def on_edit_name_clicked( ): assert _callback.from_user is not None user = await user_dao.get_by_id(_callback.from_user.id) + if not user: await _callback.answer("❌ Пользователь не найден") return @@ -101,6 +108,7 @@ async def on_edit_group_clicked( ): assert _callback.from_user is not None user = await user_dao.get_by_id(_callback.from_user.id) + if not user: await _callback.answer("❌ Пользователь не найден") return @@ -139,8 +147,15 @@ async def on_name_input( return name = message.text.strip()[:128] - await user_dao.update(message.from_user.id, name=name, name_updated_at=datetime.now(timezone.utc)) - await message.answer("✅ Имя обновлено") + result = await user_dao.update( + user_id=message.from_user.id, + name=name, + name_updated_at=now_msk(), + ) + if result: + await message.answer("✅ Имя обновлено") + else: + await message.answer("❌ Не удалось обновить имя") await manager.switch_to(UserMenuSG.main) @@ -159,8 +174,15 @@ async def on_group_selected( user_dao: FromDishka[UserDAO], ): assert _callback.from_user is not None - await user_dao.update(_callback.from_user.id, group=int(item_id), group_updated_at=datetime.now(timezone.utc)) - await _callback.answer("✅ Группа обновлена") + result = await user_dao.update( + user_id=_callback.from_user.id, + group=int(item_id), + group_updated_at=now_msk(), + ) + if result: + await _callback.answer("✅ Группа обновлена") + else: + await _callback.answer("❌ Не удалось обновить группу") await manager.switch_to(UserMenuSG.main) @@ -255,9 +277,11 @@ async def get_test_detail( attempts = await attempt_repo.get_user_test_attempts(user_id, test_id) finished_attempts = [a for a in attempts if a.finished_at] - password_str = f"🔒 Требуется пароль" if test.password else "🔓 Без пароля" + password_str = "🔒 Требуется пароль" if test.password else "🔓 Без пароля" attempts_str = f"🔄 Попыток: {len(finished_attempts)}/{test.attempts}" if test.attempts else f"🔄 Попыток: {len(finished_attempts)}/♾️" - expires_str = f"📅 До {test.expires_at.strftime('%d.%m.%Y %H:%M')}" if test.expires_at else "📅 Без срока" + + expires_at_msk = to_msk(test.expires_at) + expires_str = f"📅 До {expires_at_msk.strftime('%d.%m.%Y %H:%M')}" if expires_at_msk else "📅 Без срока" group_str = f"🎓 Для группы {test.for_group}" if test.for_group else "👥 Для всех" test_info = ( @@ -286,7 +310,8 @@ async def get_my_results( results = [] for attempt, test_title in attempts_with_tests: status = "✅" if attempt.is_passed else "❌" - date_str = attempt.finished_at.strftime("%d.%m.%Y") if attempt.finished_at else "" + finished_at_msk = to_msk(attempt.finished_at) + date_str = finished_at_msk.strftime("%d.%m.%Y") if finished_at_msk else "" results.append((f"{status} {test_title} — {attempt.score}% ({date_str})", attempt.id)) return { @@ -325,7 +350,8 @@ async def get_result_detail( test_title = test.title if test else "Неизвестный тест" status = "✅ Пройден" if attempt.is_passed else "❌ Не пройден" - date_str = attempt.finished_at.strftime("%d.%m.%Y %H:%M") if attempt.finished_at else "—" + finished_at_msk = to_msk(attempt.finished_at) + date_str = finished_at_msk.strftime("%d.%m.%Y %H:%M") if finished_at_msk else "—" lines = [ f"📝 {test_title}\n", diff --git a/src/trudex/application/bot/user_dialogs/take_test.py b/src/trudex/application/bot/user_dialogs/take_test.py index bcc4d1c..6c40969 100644 --- a/src/trudex/application/bot/user_dialogs/take_test.py +++ b/src/trudex/application/bot/user_dialogs/take_test.py @@ -1,5 +1,3 @@ -from datetime import datetime, timezone - from aiogram.types import CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.input import MessageInput @@ -14,6 +12,7 @@ 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 async def get_state_for_question_type(question_type: str): @@ -51,7 +50,7 @@ async def on_start_test( await _callback.answer("❌ Тест деактивирован") return - if test.expires_at and test.expires_at < datetime.now(timezone.utc): + if test.expires_at and test.expires_at < now_msk(): await _callback.answer("❌ Срок действия теста истек") return diff --git a/src/trudex/infrastructure/database/dao/user.py b/src/trudex/infrastructure/database/dao/user.py index c4144b8..1d08789 100644 --- a/src/trudex/infrastructure/database/dao/user.py +++ b/src/trudex/infrastructure/database/dao/user.py @@ -108,7 +108,7 @@ class UserDAO: last_name: str | None = None, name: str | None = None, group: int | None = None, - is_admin: bool = False, + is_admin: bool | None = None, ) -> DomainUser: result = await self.session.execute( select(User).where(User.id == user_id) @@ -139,5 +139,5 @@ class UserDAO: last_name=last_name, name=name, group=group, - is_admin=is_admin, + is_admin=is_admin or False, ) diff --git a/src/trudex/infrastructure/database/repo/test_attempt.py b/src/trudex/infrastructure/database/repo/test_attempt.py index 9a822c2..b4fcbd4 100644 --- a/src/trudex/infrastructure/database/repo/test_attempt.py +++ b/src/trudex/infrastructure/database/repo/test_attempt.py @@ -1,4 +1,3 @@ -from datetime import datetime from typing import final from sqlalchemy import func, select @@ -13,6 +12,7 @@ from trudex.infrastructure.database.dto.user_answer import UserAnswerDTO 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 @final @@ -132,7 +132,7 @@ class TestAttemptRepository: async def finish_attempt(self, attempt_id: int, score: int, is_passed: bool) -> TestAttempt | None: return await self.attempt_dao.update( attempt_id=attempt_id, - finished_at=datetime.utcnow(), + finished_at=now_msk(), score=score, is_passed=is_passed ) diff --git a/src/trudex/infrastructure/scheduling/tasks.py b/src/trudex/infrastructure/scheduling/tasks.py index baefebf..0a99ccf 100644 --- a/src/trudex/infrastructure/scheduling/tasks.py +++ b/src/trudex/infrastructure/scheduling/tasks.py @@ -1,9 +1,7 @@ -from datetime import datetime - from dishka import AsyncContainer from trudex.infrastructure.database.dao.test import TestDAO -from trudex.infrastructure.database.models import Test +from trudex.infrastructure.utils.timezone import now_msk async def deactivate_expired_tests(container: AsyncContainer): @@ -13,5 +11,5 @@ async def deactivate_expired_tests(container: AsyncContainer): tests = await test_dao.get_all() for test in tests: - if test.expires_at and test.expires_at < datetime.utcnow() and test.is_active: + if test.expires_at and test.expires_at < now_msk() and test.is_active: await test_dao.update(test.id, is_active=False) diff --git a/src/trudex/infrastructure/utils/timezone.py b/src/trudex/infrastructure/utils/timezone.py new file mode 100644 index 0000000..dea7c88 --- /dev/null +++ b/src/trudex/infrastructure/utils/timezone.py @@ -0,0 +1,16 @@ +from datetime import datetime +from zoneinfo import ZoneInfo + +MSK_TZ = ZoneInfo("Europe/Moscow") + + +def now_msk() -> datetime: + return datetime.now(MSK_TZ) + + +def to_msk(dt: datetime | None) -> datetime | None: + if dt is None: + return None + if dt.tzinfo is None: + return dt.replace(tzinfo=MSK_TZ) + return dt.astimezone(MSK_TZ)