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:
"""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)
@@ -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_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)
@@ -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)
@@ -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)
@@ -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("✅ Группа обновлена")
@@ -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(
@@ -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
@@ -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)
@@ -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,
)
@@ -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
)
@@ -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:
+6 -11
View File
@@ -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)
+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.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:
+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.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,