This commit is contained in:
2026-02-20 01:15:21 +03:00
parent bcc510824b
commit b14931b8e8
15 changed files with 69 additions and 44 deletions
+4 -5
View File
@@ -59,11 +59,10 @@ def do_run_migrations(connection: Connection):
async def run_async_migrations() -> None: async def run_async_migrations() -> None:
"""In this scenario we need to create an Engine connectable = create_async_engine(
and associate a connection with the context. db_config.url,
connect_args={"server_settings": {"timezone": "UTC"}},
""" )
connectable = create_async_engine(db_config.url)
async with connectable.connect() as connection: async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations) await connection.run_sync(do_run_migrations)
@@ -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
@@ -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.types import CallbackQuery, ContentType, Message
from aiogram_dialog import Dialog, DialogManager, StartMode, Window 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): 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) await manager.switch_to(SharedCreateTestSG.input_for_group)
@@ -1,6 +1,6 @@
import asyncio import asyncio
import functools import functools
from datetime import date, datetime, time from datetime import date, datetime, time, timezone
from aiogram import Bot from aiogram import Bot
from aiogram.types import BufferedInputFile, CallbackQuery, Message from aiogram.types import BufferedInputFile, CallbackQuery, Message
@@ -520,7 +520,7 @@ async def on_date_selected_for_test(
await _callback.answer("❌ Тест не найден") await _callback.answer("❌ Тест не найден")
return 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) result = await test_service.update_expires(test_id, expires_at)
await _callback.answer(result.message) await _callback.answer(result.message)
await manager.switch_to(SharedTestsSG.test_detail) await manager.switch_to(SharedTestsSG.test_detail)
@@ -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 import TestRepository
from quizzi.infrastructure.database.repo.test_attempt import TestAttemptRepository from quizzi.infrastructure.database.repo.test_attempt import TestAttemptRepository
from quizzi.infrastructure.utils.rate_limiter import PasswordRateLimiter 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 @inject
@@ -137,7 +137,7 @@ async def start_test_without_password(
return return
attempt = await attempt_repo.attempt_dao.create(user_id=user_id, test_id=test_id) 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) first_question, _ = await test_repo.get_question_with_options(questions[0].id)
@@ -20,7 +20,7 @@ from quizzi.infrastructure.database.repo.test_attempt import TestAttemptReposito
from quizzi.infrastructure.utils.config import Config from quizzi.infrastructure.utils.config import Config
from quizzi.infrastructure.utils.qr_generator import generate_qr_bytes 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.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: def can_edit_field(updated_at: datetime | None) -> bool:
@@ -28,13 +28,17 @@ def can_edit_field(updated_at: datetime | None) -> bool:
return True return True
updated_at_msk = to_msk(updated_at) updated_at_msk = to_msk(updated_at)
assert updated_at_msk is not None 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: def get_remaining_time(updated_at: datetime) -> str:
updated_at_msk = to_msk(updated_at) updated_at_msk = to_msk(updated_at)
assert updated_at_msk is not None 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) hours = int(remaining.total_seconds() // 3600)
minutes = int((remaining.total_seconds() % 3600) // 60) minutes = int((remaining.total_seconds() % 3600) // 60)
return f"{hours}ч {minutes}м" return f"{hours}ч {minutes}м"
@@ -149,7 +153,7 @@ async def on_name_input(
result = await user_dao.update( result = await user_dao.update(
user_id=message.from_user.id, user_id=message.from_user.id,
name=name, name=name,
name_updated_at=now_msk_naive(), name_updated_at=now_utc_naive(),
) )
if result: if result:
await message.answer("✅ Имя обновлено") await message.answer("✅ Имя обновлено")
@@ -176,7 +180,7 @@ async def on_group_selected(
result = await user_dao.update( result = await user_dao.update(
user_id=_callback.from_user.id, user_id=_callback.from_user.id,
group=int(item_id), group=int(item_id),
group_updated_at=now_msk_naive(), group_updated_at=now_utc_naive(),
) )
if result: if result:
await _callback.answer("✅ Группа обновлена") await _callback.answer("✅ Группа обновлена")
@@ -9,7 +9,7 @@ from dishka.integrations.aiogram_dialog import inject
from quizzi.application.bot.user_dialogs.states import UserDeeplinkSG, UserMenuSG, UserRegistrationSG from quizzi.application.bot.user_dialogs.states import UserDeeplinkSG, UserMenuSG, UserRegistrationSG
from quizzi.infrastructure.database.dao.group import GroupDAO from quizzi.infrastructure.database.dao.group import GroupDAO
from quizzi.infrastructure.database.dao.user import UserDAO 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 @inject
@@ -40,7 +40,7 @@ async def on_name_input(
pending_test_id = start_data.get("pending_test_id") pending_test_id = start_data.get("pending_test_id")
if user_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 manager.dialog_data["name"] = name
@@ -80,7 +80,7 @@ async def on_group_selected(
pending_test_id = start_data.get("pending_test_id") pending_test_id = start_data.get("pending_test_id")
if user_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: if pending_test_id:
await manager.start( await manager.start(
@@ -196,7 +196,7 @@ async def on_start_test(
await _callback.answer("❌ Тест деактивирован") await _callback.answer("❌ Тест деактивирован")
return 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("❌ Срок действия теста истек") await _callback.answer("❌ Срок действия теста истек")
return return
@@ -8,6 +8,7 @@ def new_session_maker(db_url: str) -> async_sessionmaker[AsyncSession]:
max_overflow=15, max_overflow=15,
connect_args={ connect_args={
"timeout": 5, "timeout": 5,
"server_settings": {"timezone": "UTC"},
}, },
) )
return async_sessionmaker(engine, class_=AsyncSession, autoflush=False, expire_on_commit=False) return async_sessionmaker(engine, class_=AsyncSession, autoflush=False, expire_on_commit=False)
@@ -1,4 +1,4 @@
from datetime import datetime from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -34,7 +34,7 @@ class TestAttemptDAO:
attempt = TestAttempt( attempt = TestAttempt(
user_id=user_id, user_id=user_id,
test_id=test_id, test_id=test_id,
started_at=datetime.utcnow(), started_at=datetime.now(timezone.utc).replace(tzinfo=None),
score=score, score=score,
is_passed=is_passed, is_passed=is_passed,
) )
@@ -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 TestAttempt as TestAttemptModel
from quizzi.infrastructure.database.models import User as UserModel from quizzi.infrastructure.database.models import User as UserModel
from quizzi.infrastructure.database.models import UserAnswer as UserAnswerModel 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 @final
@@ -135,7 +135,7 @@ class TestAttemptRepository:
async def finish_attempt(self, attempt_id: int, score: int, is_passed: bool) -> TestAttempt | None: async def finish_attempt(self, attempt_id: int, score: int, is_passed: bool) -> TestAttempt | None:
return await self.attempt_dao.update( return await self.attempt_dao.update(
attempt_id=attempt_id, attempt_id=attempt_id,
finished_at=now_msk_naive(), finished_at=now_utc_naive(),
score=score, score=score,
is_passed=is_passed is_passed=is_passed
) )
@@ -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.dao.user_answer import UserAnswerDAO
from quizzi.infrastructure.database.repo.test import TestRepository from quizzi.infrastructure.database.repo.test import TestRepository
from quizzi.infrastructure.database.repo.test_attempt import TestAttemptRepository 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__) logger = logging.getLogger(__name__)
@@ -18,7 +18,7 @@ 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)
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: for test in expired_tests:
await test_dao.update(test.id, is_active=False) 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) test_repo = await request_container.get(TestRepository)
answer_dao = await request_container.get(UserAnswerDAO) 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) expired_attempts = await attempt_repo.get_expired_active_attempts(now)
for attempt, _ in expired_attempts: 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) attempt_repo = await request_container.get(TestAttemptRepository)
test_repo = await request_container.get(TestRepository) 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) attempts_needing_warning = await attempt_repo.get_attempts_needing_warning(now)
for attempt, time_limit, questions_count in attempts_needing_warning: for attempt, time_limit, questions_count in attempts_needing_warning:
+6 -11
View File
@@ -1,24 +1,19 @@
from datetime import datetime from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
MSK_TZ = ZoneInfo("Europe/Moscow") MSK_TZ = timezone(timedelta(hours=3))
def now_msk() -> datetime: def now_utc() -> datetime:
return datetime.now(MSK_TZ) return datetime.now(timezone.utc)
def now_msk_naive() -> datetime:
return datetime.now(MSK_TZ).replace(tzinfo=None)
def now_utc_naive() -> datetime: def now_utc_naive() -> datetime:
return datetime.utcnow() return datetime.now(timezone.utc).replace(tzinfo=None)
def to_msk(dt: datetime | None) -> datetime | None: def to_msk(dt: datetime | None) -> datetime | None:
if dt is None: if dt is None:
return None return None
if dt.tzinfo is None: if dt.tzinfo is None:
return dt.replace(tzinfo=MSK_TZ) dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(MSK_TZ) return dt.astimezone(MSK_TZ)
+3 -3
View File
@@ -8,7 +8,7 @@ from quizzi.infrastructure.database.repo.test import TestRepository
from quizzi.infrastructure.database.repo.test_attempt import TestAttemptRepository from quizzi.infrastructure.database.repo.test_attempt import TestAttemptRepository
from quizzi.infrastructure.utils.config import Config from quizzi.infrastructure.utils.config import Config
from quizzi.infrastructure.utils.test_id_to_hash import decode_id, encode_id 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 @dataclass
@@ -68,7 +68,7 @@ class TestService:
if not test.is_active: if not test.is_active:
return TestValidationResult(is_valid=False, error="❌ Тест деактивирован", test=test) 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) return TestValidationResult(is_valid=False, error="❌ Срок действия теста истек", test=test)
user = await self._user_dao.get_by_id(user_id) user = await self._user_dao.get_by_id(user_id)
@@ -90,7 +90,7 @@ class TestService:
if not test.is_active: if not test.is_active:
return TestAccessResult(can_access=False, error="❌ Тест деактивирован") 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="❌ Срок действия теста истек") return TestAccessResult(can_access=False, error="❌ Срок действия теста истек")
if test.attempts: if test.attempts:
+2 -2
View File
@@ -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.dao.user_answer import UserAnswerDAO
from quizzi.infrastructure.database.repo.test import TestRepository from quizzi.infrastructure.database.repo.test import TestRepository
from quizzi.infrastructure.database.repo.test_attempt import TestAttemptRepository 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 @dataclass
@@ -56,7 +56,7 @@ class TestAttemptService:
return AttemptStartResult(success=False, error="❌ В тесте нет вопросов") return AttemptStartResult(success=False, error="❌ В тесте нет вопросов")
attempt = await self._attempt_repo.attempt_dao.create(user_id=user_id, test_id=test_id) 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( return AttemptStartResult(
success=True, success=True,