This commit is contained in:
2026-01-07 00:10:25 +03:00
parent 7d2a734b7d
commit ebdc9954de
15 changed files with 600 additions and 42 deletions
@@ -10,7 +10,6 @@ from quizzi.infrastructure.database.models import Test
class _UNSET:
"""Sentinel для различения None и "не передано"."""
pass
@@ -46,6 +46,7 @@ class TestAttemptDAO:
self,
attempt_id: int,
finished_at: datetime | None = None,
warning_sent_at: datetime | None = None,
score: int | None = None,
is_passed: bool | None = None,
) -> DomainTestAttempt | None:
@@ -58,6 +59,8 @@ class TestAttemptDAO:
if finished_at is not None:
attempt.finished_at = finished_at
if warning_sent_at is not None:
attempt.warning_sent_at = warning_sent_at
if score is not None:
attempt.score = score
if is_passed is not None:
@@ -9,7 +9,6 @@ from quizzi.infrastructure.database.models import User
class _UNSET:
"""Sentinel для различения None и "не передано"."""
pass
@@ -13,6 +13,7 @@ class TestAttemptDTO:
test_id=self.model.test_id,
started_at=self.model.started_at,
finished_at=self.model.finished_at,
warning_sent_at=self.model.warning_sent_at,
score=self.model.score,
is_passed=self.model.is_passed,
)
@@ -105,6 +105,7 @@ class TestAttempt(Base):
test_id: Mapped[int] = mapped_column(ForeignKey("tests.id"), index=True)
started_at: Mapped[datetime] = mapped_column(server_default=func.now())
finished_at: Mapped[datetime | None] = mapped_column(default=None)
warning_sent_at: Mapped[datetime | None] = mapped_column(default=None)
score: Mapped[int] = mapped_column(Integer, default=0)
is_passed: Mapped[bool] = mapped_column(default=False)
@@ -125,7 +125,6 @@ class TestRepository:
async def get_questions_with_options_by_ids(
self, question_ids: list[int]
) -> dict[int, tuple[Question, list[Option]]]:
"""Загружает вопросы с опциями по списку ID за один запрос."""
if not question_ids:
return {}
@@ -1,3 +1,4 @@
from datetime import datetime
from typing import final
from sqlalchemy import func, select
@@ -9,7 +10,10 @@ from quizzi.infrastructure.database.dao.test_attempt import TestAttemptDAO
from quizzi.infrastructure.database.dao.user_answer import UserAnswerDAO
from quizzi.infrastructure.database.dto.test_attempt import TestAttemptDTO
from quizzi.infrastructure.database.dto.user_answer import UserAnswerDTO
from quizzi.infrastructure.database.models import Question as QuestionModel
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
@@ -173,8 +177,6 @@ class TestAttemptRepository:
}
async def get_most_difficult_questions(self, test_id: int, limit: int = 10) -> list[tuple[int, float]]:
from quizzi.infrastructure.database.models import Question as QuestionModel
result = await self.session.execute(
select(
UserAnswerModel.question_id,
@@ -209,8 +211,6 @@ class TestAttemptRepository:
}
async def get_finished_attempts_with_tests(self, user_id: int) -> list[tuple[TestAttempt, str]]:
from quizzi.infrastructure.database.models import Test as TestModel
result = await self.session.execute(
select(TestAttemptModel, TestModel.title)
.join(TestModel, TestAttemptModel.test_id == TestModel.id)
@@ -222,8 +222,6 @@ class TestAttemptRepository:
return [(TestAttemptDTO(row[0]).to_domain(), row[1]) for row in rows]
async def get_test_attempts_with_users(self, test_id: int) -> list[tuple[TestAttempt, str]]:
from quizzi.infrastructure.database.models import User as UserModel
result = await self.session.execute(
select(TestAttemptModel, UserModel.name, UserModel.first_name)
.join(UserModel, TestAttemptModel.user_id == UserModel.id)
@@ -233,3 +231,56 @@ class TestAttemptRepository:
)
rows = result.all()
return [(TestAttemptDTO(row[0]).to_domain(), row[1] or row[2]) for row in rows]
async def get_expired_active_attempts(self, now: datetime) -> list[tuple[TestAttempt, int]]:
result = await self.session.execute(
select(TestAttemptModel, TestModel.time_limit)
.join(TestModel, TestAttemptModel.test_id == TestModel.id)
.where(TestAttemptModel.finished_at.is_(None))
.where(TestModel.time_limit.isnot(None))
)
rows = result.all()
expired = []
for attempt_model, time_limit in rows:
if time_limit:
elapsed = (now - attempt_model.started_at).total_seconds()
if elapsed >= time_limit:
expired.append((TestAttemptDTO(attempt_model).to_domain(), time_limit))
return expired
async def get_attempts_needing_warning(self, now: datetime) -> list[tuple[TestAttempt, int, int]]:
result = await self.session.execute(
select(
TestAttemptModel,
TestModel.time_limit,
func.count(QuestionModel.id).label("questions_count")
)
.join(TestModel, TestAttemptModel.test_id == TestModel.id)
.join(QuestionModel, QuestionModel.test_id == TestModel.id)
.where(TestAttemptModel.finished_at.is_(None))
.where(TestAttemptModel.warning_sent_at.is_(None))
.where(TestModel.time_limit.isnot(None))
.group_by(TestAttemptModel.id, TestModel.time_limit)
)
rows = result.all()
needing_warning = []
for attempt_model, time_limit, questions_count in rows:
if time_limit and questions_count > 0:
elapsed = (now - attempt_model.started_at).total_seconds()
time_remaining = time_limit - elapsed
threshold = time_limit * 0.1
if time_remaining <= threshold and time_remaining > 0:
needing_warning.append((
TestAttemptDTO(attempt_model).to_domain(),
time_limit,
questions_count
))
return needing_warning
async def mark_warning_sent(self, attempt_id: int, sent_at: datetime) -> None:
await self.attempt_dao.update(attempt_id=attempt_id, warning_sent_at=sent_at)
+19 -2
View File
@@ -1,6 +1,7 @@
import logging
from collections.abc import AsyncIterable
from aiogram import Bot
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from dishka import AsyncContainer, Provider, Scope, provide
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
@@ -16,7 +17,7 @@ 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.database.repo.user import UserRepository
from quizzi.infrastructure.scheduling.tasks import deactivate_expired_tests
from quizzi.infrastructure.scheduling.tasks import deactivate_expired_tests, finish_expired_test_attempts, send_time_warning_notifications
from quizzi.infrastructure.utils.config import Config
from quizzi.infrastructure.utils.rate_limiter import PasswordRateLimiter
@@ -81,7 +82,7 @@ class DatabaseProvider(Provider):
class SchedulerProvider(Provider):
@provide(scope = Scope.APP)
def get_scheduler(self, container: AsyncContainer) -> AsyncIOScheduler:
def get_scheduler(self, container: AsyncContainer, bot: Bot) -> AsyncIOScheduler:
logging.getLogger('apscheduler').setLevel(logging.WARNING)
scheduler = AsyncIOScheduler()
@@ -93,4 +94,20 @@ class SchedulerProvider(Provider):
id='deactivate_expired_tests',
)
scheduler.add_job(
finish_expired_test_attempts,
'interval',
minutes=1,
args=[container, bot],
id='finish_expired_test_attempts',
)
scheduler.add_job(
send_time_warning_notifications,
'interval',
seconds=10,
args=[container, bot],
id='send_time_warning_notifications',
)
return scheduler
@@ -1,8 +1,14 @@
import logging
from aiogram import Bot
from aiogram.exceptions import TelegramAPIError
from dishka import AsyncContainer
from sqlalchemy.exc import SQLAlchemyError
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
logger = logging.getLogger(__name__)
@@ -17,3 +23,120 @@ async def deactivate_expired_tests(container: AsyncContainer) -> None:
for test in expired_tests:
await test_dao.update(test.id, is_active=False)
logger.info("Деактивирован истёкший тест: id=%d, title=%s", test.id, test.title)
async def finish_expired_test_attempts(container: AsyncContainer, bot: Bot) -> None:
async with container() as request_container:
attempt_repo = await request_container.get(TestAttemptRepository)
test_repo = await request_container.get(TestRepository)
answer_dao = await request_container.get(UserAnswerDAO)
now = now_msk_naive()
expired_attempts = await attempt_repo.get_expired_active_attempts(now)
for attempt, _ in expired_attempts:
try:
test, questions_with_options = await test_repo.get_full_test(attempt.test_id)
if not test:
continue
question_ids = [q.id for q, _ in questions_with_options]
answered_question_ids = set()
answers = await attempt_repo.get_answers_for_attempt(attempt.id)
for answer in answers:
answered_question_ids.add(answer.question_id)
for question_id in question_ids:
if question_id not in answered_question_ids:
await answer_dao.create(
attempt_id=attempt.id,
question_id=question_id,
text_answer=None,
is_correct=False,
)
correct_count = await attempt_repo.calculate_attempt_score(attempt.id)
total_questions = len(question_ids)
score = round((correct_count / total_questions * 100)) if total_questions > 0 else 0
is_passed = score >= 50
await attempt_repo.finish_attempt(attempt.id, score, is_passed)
status = "пройден ✅" if is_passed else "не пройден ❌"
try:
await bot.send_message(
attempt.user_id,
f"⏰ <b>Время на прохождение теста истекло!</b>\n\n"
f"📝 <b>Тест:</b> {test.title}\n"
f"📊 <b>Результат:</b> {score}%\n"
f"🏆 <b>Статус:</b> {status}\n\n"
f"<i>Неотвеченные вопросы засчитаны как неправильные.</i>"
)
except TelegramAPIError as e:
logger.warning("Не удалось отправить уведомление пользователю %d: %s", attempt.user_id, e)
logger.info(
"Завершена просроченная попытка: attempt_id=%d, user_id=%d, test_id=%d, score=%d%%",
attempt.id, attempt.user_id, attempt.test_id, score
)
except SQLAlchemyError as e:
logger.error("Ошибка при завершении попытки %d: %s", attempt.id, e)
async def send_time_warning_notifications(container: AsyncContainer, bot: Bot) -> None:
async with container() as request_container:
attempt_repo = await request_container.get(TestAttemptRepository)
test_repo = await request_container.get(TestRepository)
now = now_msk_naive()
attempts_needing_warning = await attempt_repo.get_attempts_needing_warning(now)
for attempt, time_limit, questions_count in attempts_needing_warning:
try:
answers = await attempt_repo.get_answers_for_attempt(attempt.id)
if answers:
avg_time_per_question = time_limit / questions_count
time_since_start = (now - attempt.started_at).total_seconds()
expected_answers = int(time_since_start / avg_time_per_question)
if len(answers) >= expected_answers:
continue
test, _ = await test_repo.get_full_test(attempt.test_id)
if not test:
continue
elapsed = (now - attempt.started_at).total_seconds()
remaining_seconds = int(time_limit - elapsed)
remaining_minutes = remaining_seconds // 60
remaining_secs = remaining_seconds % 60
if remaining_minutes > 0:
time_str = f"{remaining_minutes} мин {remaining_secs} сек"
else:
time_str = f"{remaining_secs} сек"
try:
await bot.send_message(
attempt.user_id,
f"⚠️ <b>Внимание! Время заканчивается!</b>\n\n"
f"📝 <b>Тест:</b> {test.title}\n"
f"⏱️ <b>Осталось:</b> {time_str}\n\n"
f"<i>Поторопитесь с ответами!</i>"
)
except TelegramAPIError as e:
logger.warning("Не удалось отправить предупреждение пользователю %d: %s", attempt.user_id, e)
await attempt_repo.mark_warning_sent(attempt.id, now)
logger.info(
"Отправлено предупреждение о времени: attempt_id=%d, user_id=%d, remaining=%ds",
attempt.id, attempt.user_id, remaining_seconds
)
except SQLAlchemyError as e:
logger.error("Ошибка при отправке предупреждения для попытки %d: %s", attempt.id, e)
@@ -9,7 +9,6 @@ def now_msk() -> datetime:
def now_msk_naive() -> datetime:
"""Возвращает текущее время в МСК без timezone info (для сохранения в БД)."""
return datetime.now(MSK_TZ).replace(tzinfo=None)