From b14931b8e8352df381b1a281c963d921570f82bf Mon Sep 17 00:00:00 2001 From: kolo Date: Fri, 20 Feb 2026 01:15:21 +0300 Subject: [PATCH] update --- alembic/env.py | 9 +++---- .../2ba7282de7d9_set_timezone_to_utc.py | 25 +++++++++++++++++++ .../bot/shared_dialogs/create_test.py | 5 ++-- .../application/bot/shared_dialogs/tests.py | 4 +-- .../application/bot/user_dialogs/deeplink.py | 4 +-- .../application/bot/user_dialogs/main_menu.py | 14 +++++++---- .../bot/user_dialogs/registration.py | 6 ++--- .../application/bot/user_dialogs/take_test.py | 2 +- src/quizzi/infrastructure/database/config.py | 1 + .../database/dao/test_attempt.py | 4 +-- .../database/repo/test_attempt.py | 4 +-- src/quizzi/infrastructure/scheduling/tasks.py | 8 +++--- src/quizzi/infrastructure/utils/timezone.py | 17 +++++-------- src/quizzi/service/test.py | 6 ++--- src/quizzi/service/test_attempt.py | 4 +-- 15 files changed, 69 insertions(+), 44 deletions(-) create mode 100644 alembic/versions/2ba7282de7d9_set_timezone_to_utc.py diff --git a/alembic/env.py b/alembic/env.py index 377de3b..0c80669 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -59,11 +59,10 @@ def do_run_migrations(connection: Connection): async def run_async_migrations() -> None: - """In this scenario we need to create an Engine - and associate a connection with the context. - - """ - connectable = create_async_engine(db_config.url) + connectable = create_async_engine( + db_config.url, + connect_args={"server_settings": {"timezone": "UTC"}}, + ) async with connectable.connect() as connection: await connection.run_sync(do_run_migrations) diff --git a/alembic/versions/2ba7282de7d9_set_timezone_to_utc.py b/alembic/versions/2ba7282de7d9_set_timezone_to_utc.py new file mode 100644 index 0000000..7bea2e5 --- /dev/null +++ b/alembic/versions/2ba7282de7d9_set_timezone_to_utc.py @@ -0,0 +1,25 @@ +"""set_timezone_to_utc + +Revision ID: 2ba7282de7d9 +Revises: c4d5e6f7a8b9 +Create Date: 2026-02-20 01:14:15.088425 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +revision: str = '2ba7282de7d9' +down_revision: str | None = 'c4d5e6f7a8b9' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute("SET timezone = 'UTC'") + + +def downgrade() -> None: + pass diff --git a/src/quizzi/application/bot/shared_dialogs/create_test.py b/src/quizzi/application/bot/shared_dialogs/create_test.py index 5fe23c2..10309a9 100644 --- a/src/quizzi/application/bot/shared_dialogs/create_test.py +++ b/src/quizzi/application/bot/shared_dialogs/create_test.py @@ -1,4 +1,4 @@ -from datetime import date, datetime, time +from datetime import date, datetime, time, timezone from aiogram.types import CallbackQuery, ContentType, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window @@ -139,7 +139,8 @@ async def on_skip_time_limit(_callback: CallbackQuery, _button: Button, manager: async def on_date_selected(_callback, _widget, manager: DialogManager, selected_date: date): - manager.dialog_data["expires_at"] = datetime.combine(selected_date, time.min) + expires_at = datetime.combine(selected_date, time.min, tzinfo=timezone.utc).replace(tzinfo=None) + manager.dialog_data["expires_at"] = expires_at await manager.switch_to(SharedCreateTestSG.input_for_group) diff --git a/src/quizzi/application/bot/shared_dialogs/tests.py b/src/quizzi/application/bot/shared_dialogs/tests.py index ca0f8a8..f05ca94 100644 --- a/src/quizzi/application/bot/shared_dialogs/tests.py +++ b/src/quizzi/application/bot/shared_dialogs/tests.py @@ -1,6 +1,6 @@ import asyncio import functools -from datetime import date, datetime, time +from datetime import date, datetime, time, timezone from aiogram import Bot from aiogram.types import BufferedInputFile, CallbackQuery, Message @@ -520,7 +520,7 @@ async def on_date_selected_for_test( await _callback.answer("❌ Тест не найден") return - expires_at = datetime.combine(selected_date, time.min) + expires_at = datetime.combine(selected_date, time.min, tzinfo=timezone.utc).replace(tzinfo=None) result = await test_service.update_expires(test_id, expires_at) await _callback.answer(result.message) await manager.switch_to(SharedTestsSG.test_detail) diff --git a/src/quizzi/application/bot/user_dialogs/deeplink.py b/src/quizzi/application/bot/user_dialogs/deeplink.py index 66dd043..7c27e25 100644 --- a/src/quizzi/application/bot/user_dialogs/deeplink.py +++ b/src/quizzi/application/bot/user_dialogs/deeplink.py @@ -12,7 +12,7 @@ from quizzi.infrastructure.database.models import QuestionType from quizzi.infrastructure.database.repo.test import TestRepository from quizzi.infrastructure.database.repo.test_attempt import TestAttemptRepository from quizzi.infrastructure.utils.rate_limiter import PasswordRateLimiter -from quizzi.infrastructure.utils.timezone import now_msk_naive, to_msk +from quizzi.infrastructure.utils.timezone import now_utc_naive, to_msk @inject @@ -137,7 +137,7 @@ async def start_test_without_password( return attempt = await attempt_repo.attempt_dao.create(user_id=user_id, test_id=test_id) - started_at = now_msk_naive() + started_at = now_utc_naive() first_question, _ = await test_repo.get_question_with_options(questions[0].id) diff --git a/src/quizzi/application/bot/user_dialogs/main_menu.py b/src/quizzi/application/bot/user_dialogs/main_menu.py index 6b04c1c..f1e72cb 100644 --- a/src/quizzi/application/bot/user_dialogs/main_menu.py +++ b/src/quizzi/application/bot/user_dialogs/main_menu.py @@ -20,7 +20,7 @@ from quizzi.infrastructure.database.repo.test_attempt import TestAttemptReposito from quizzi.infrastructure.utils.config import Config from quizzi.infrastructure.utils.qr_generator import generate_qr_bytes from quizzi.infrastructure.utils.test_id_to_hash import encode_id -from quizzi.infrastructure.utils.timezone import now_msk, now_msk_naive, to_msk +from quizzi.infrastructure.utils.timezone import now_utc, now_utc_naive, to_msk def can_edit_field(updated_at: datetime | None) -> bool: @@ -28,13 +28,17 @@ def can_edit_field(updated_at: datetime | None) -> bool: 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) + now_msk = to_msk(now_utc()) + assert now_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) + now_msk = to_msk(now_utc()) + assert now_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}м" @@ -149,7 +153,7 @@ async def on_name_input( result = await user_dao.update( user_id=message.from_user.id, name=name, - name_updated_at=now_msk_naive(), + name_updated_at=now_utc_naive(), ) if result: await message.answer("✅ Имя обновлено") @@ -176,7 +180,7 @@ async def on_group_selected( result = await user_dao.update( user_id=_callback.from_user.id, group=int(item_id), - group_updated_at=now_msk_naive(), + group_updated_at=now_utc_naive(), ) if result: await _callback.answer("✅ Группа обновлена") diff --git a/src/quizzi/application/bot/user_dialogs/registration.py b/src/quizzi/application/bot/user_dialogs/registration.py index 8b5407a..d99ecdf 100644 --- a/src/quizzi/application/bot/user_dialogs/registration.py +++ b/src/quizzi/application/bot/user_dialogs/registration.py @@ -9,7 +9,7 @@ from dishka.integrations.aiogram_dialog import inject from quizzi.application.bot.user_dialogs.states import UserDeeplinkSG, UserMenuSG, UserRegistrationSG from quizzi.infrastructure.database.dao.group import GroupDAO from quizzi.infrastructure.database.dao.user import UserDAO -from quizzi.infrastructure.utils.timezone import now_msk_naive +from quizzi.infrastructure.utils.timezone import now_utc_naive @inject @@ -40,7 +40,7 @@ async def on_name_input( pending_test_id = start_data.get("pending_test_id") if user_id: - await user_dao.update(user_id=user_id, name=name, name_updated_at=now_msk_naive()) + await user_dao.update(user_id=user_id, name=name, name_updated_at=now_utc_naive()) manager.dialog_data["name"] = name @@ -80,7 +80,7 @@ async def on_group_selected( pending_test_id = start_data.get("pending_test_id") if user_id: - await user_dao.update(user_id=user_id, group=int(item_id), group_updated_at=now_msk_naive()) + await user_dao.update(user_id=user_id, group=int(item_id), group_updated_at=now_utc_naive()) if pending_test_id: await manager.start( diff --git a/src/quizzi/application/bot/user_dialogs/take_test.py b/src/quizzi/application/bot/user_dialogs/take_test.py index a34992a..ea85eb1 100644 --- a/src/quizzi/application/bot/user_dialogs/take_test.py +++ b/src/quizzi/application/bot/user_dialogs/take_test.py @@ -196,7 +196,7 @@ async def on_start_test( await _callback.answer("❌ Тест деактивирован") return - if test.expires_at and test.expires_at < now_msk_naive(): + if test.expires_at and test.expires_at < now_utc_naive(): await _callback.answer("❌ Срок действия теста истек") return diff --git a/src/quizzi/infrastructure/database/config.py b/src/quizzi/infrastructure/database/config.py index fba9003..69ea5bc 100644 --- a/src/quizzi/infrastructure/database/config.py +++ b/src/quizzi/infrastructure/database/config.py @@ -8,6 +8,7 @@ def new_session_maker(db_url: str) -> async_sessionmaker[AsyncSession]: max_overflow=15, connect_args={ "timeout": 5, + "server_settings": {"timezone": "UTC"}, }, ) return async_sessionmaker(engine, class_=AsyncSession, autoflush=False, expire_on_commit=False) diff --git a/src/quizzi/infrastructure/database/dao/test_attempt.py b/src/quizzi/infrastructure/database/dao/test_attempt.py index 0916bbb..b596a2e 100644 --- a/src/quizzi/infrastructure/database/dao/test_attempt.py +++ b/src/quizzi/infrastructure/database/dao/test_attempt.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -34,7 +34,7 @@ class TestAttemptDAO: attempt = TestAttempt( user_id=user_id, test_id=test_id, - started_at=datetime.utcnow(), + started_at=datetime.now(timezone.utc).replace(tzinfo=None), score=score, is_passed=is_passed, ) diff --git a/src/quizzi/infrastructure/database/repo/test_attempt.py b/src/quizzi/infrastructure/database/repo/test_attempt.py index 524a9ea..e472932 100644 --- a/src/quizzi/infrastructure/database/repo/test_attempt.py +++ b/src/quizzi/infrastructure/database/repo/test_attempt.py @@ -15,7 +15,7 @@ from quizzi.infrastructure.database.models import Test as TestModel from quizzi.infrastructure.database.models import TestAttempt as TestAttemptModel from quizzi.infrastructure.database.models import User as UserModel from quizzi.infrastructure.database.models import UserAnswer as UserAnswerModel -from quizzi.infrastructure.utils.timezone import now_msk_naive +from quizzi.infrastructure.utils.timezone import now_utc_naive @final @@ -135,7 +135,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=now_msk_naive(), + finished_at=now_utc_naive(), score=score, is_passed=is_passed ) diff --git a/src/quizzi/infrastructure/scheduling/tasks.py b/src/quizzi/infrastructure/scheduling/tasks.py index 0236ebd..d1ae6b0 100644 --- a/src/quizzi/infrastructure/scheduling/tasks.py +++ b/src/quizzi/infrastructure/scheduling/tasks.py @@ -9,7 +9,7 @@ from quizzi.infrastructure.database.dao.test import TestDAO from quizzi.infrastructure.database.dao.user_answer import UserAnswerDAO from quizzi.infrastructure.database.repo.test import TestRepository from quizzi.infrastructure.database.repo.test_attempt import TestAttemptRepository -from quizzi.infrastructure.utils.timezone import now_msk_naive +from quizzi.infrastructure.utils.timezone import now_utc_naive logger = logging.getLogger(__name__) @@ -18,7 +18,7 @@ async def deactivate_expired_tests(container: AsyncContainer) -> None: async with container() as request_container: test_dao = await request_container.get(TestDAO) - expired_tests = await test_dao.get_expired_active_tests(now_msk_naive()) + expired_tests = await test_dao.get_expired_active_tests(now_utc_naive()) for test in expired_tests: await test_dao.update(test.id, is_active=False) @@ -31,7 +31,7 @@ async def finish_expired_test_attempts(container: AsyncContainer, bot: Bot) -> N test_repo = await request_container.get(TestRepository) answer_dao = await request_container.get(UserAnswerDAO) - now = now_msk_naive() + now = now_utc_naive() expired_attempts = await attempt_repo.get_expired_active_attempts(now) for attempt, _ in expired_attempts: @@ -91,7 +91,7 @@ async def send_time_warning_notifications(container: AsyncContainer, bot: Bot) - attempt_repo = await request_container.get(TestAttemptRepository) test_repo = await request_container.get(TestRepository) - now = now_msk_naive() + now = now_utc_naive() attempts_needing_warning = await attempt_repo.get_attempts_needing_warning(now) for attempt, time_limit, questions_count in attempts_needing_warning: diff --git a/src/quizzi/infrastructure/utils/timezone.py b/src/quizzi/infrastructure/utils/timezone.py index 8d483e5..6cc7741 100644 --- a/src/quizzi/infrastructure/utils/timezone.py +++ b/src/quizzi/infrastructure/utils/timezone.py @@ -1,24 +1,19 @@ -from datetime import datetime -from zoneinfo import ZoneInfo +from datetime import datetime, timedelta, timezone -MSK_TZ = ZoneInfo("Europe/Moscow") +MSK_TZ = timezone(timedelta(hours=3)) -def now_msk() -> datetime: - return datetime.now(MSK_TZ) - - -def now_msk_naive() -> datetime: - return datetime.now(MSK_TZ).replace(tzinfo=None) +def now_utc() -> datetime: + return datetime.now(timezone.utc) def now_utc_naive() -> datetime: - return datetime.utcnow() + return datetime.now(timezone.utc).replace(tzinfo=None) 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) + dt = dt.replace(tzinfo=timezone.utc) return dt.astimezone(MSK_TZ) diff --git a/src/quizzi/service/test.py b/src/quizzi/service/test.py index 8128f8a..a9f9783 100644 --- a/src/quizzi/service/test.py +++ b/src/quizzi/service/test.py @@ -8,7 +8,7 @@ from quizzi.infrastructure.database.repo.test import TestRepository from quizzi.infrastructure.database.repo.test_attempt import TestAttemptRepository from quizzi.infrastructure.utils.config import Config from quizzi.infrastructure.utils.test_id_to_hash import decode_id, encode_id -from quizzi.infrastructure.utils.timezone import now_msk_naive +from quizzi.infrastructure.utils.timezone import now_utc_naive @dataclass @@ -68,7 +68,7 @@ class TestService: if not test.is_active: return TestValidationResult(is_valid=False, error="❌ Тест деактивирован", test=test) - if test.expires_at and test.expires_at < now_msk_naive(): + if test.expires_at and test.expires_at < now_utc_naive(): return TestValidationResult(is_valid=False, error="❌ Срок действия теста истек", test=test) user = await self._user_dao.get_by_id(user_id) @@ -90,7 +90,7 @@ class TestService: if not test.is_active: return TestAccessResult(can_access=False, error="❌ Тест деактивирован") - if test.expires_at and test.expires_at < now_msk_naive(): + if test.expires_at and test.expires_at < now_utc_naive(): return TestAccessResult(can_access=False, error="❌ Срок действия теста истек") if test.attempts: diff --git a/src/quizzi/service/test_attempt.py b/src/quizzi/service/test_attempt.py index fa689df..49bcebe 100644 --- a/src/quizzi/service/test_attempt.py +++ b/src/quizzi/service/test_attempt.py @@ -6,7 +6,7 @@ from quizzi.infrastructure.database.dao.test import TestDAO from quizzi.infrastructure.database.dao.user_answer import UserAnswerDAO from quizzi.infrastructure.database.repo.test import TestRepository from quizzi.infrastructure.database.repo.test_attempt import TestAttemptRepository -from quizzi.infrastructure.utils.timezone import now_msk_naive +from quizzi.infrastructure.utils.timezone import now_utc_naive @dataclass @@ -56,7 +56,7 @@ class TestAttemptService: return AttemptStartResult(success=False, error="❌ В тесте нет вопросов") attempt = await self._attempt_repo.attempt_dao.create(user_id=user_id, test_id=test_id) - started_at = now_msk_naive() + started_at = now_utc_naive() return AttemptStartResult( success=True,