Files
qa_test_app/backend/app/api/attempts.py
T
Aleksey Razorvin fc684e7c7d Спринт 5: Трекер результатов
- Миграция 005: user_id в test_attempts (дефолт 1 = Гость)
- GET /api/attempts с фильтрами по тесту, дате и пагинацией
- Страница /tracker: таблица попыток, фильтры, пагинация
- Ссылка «Трекер» в шапке приложения

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 15:26:40 +05:00

283 lines
9.5 KiB
Python

import random
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.attempt import AttemptAnswer, TestAttempt
from app.models.test import Question, Test
from app.schemas.attempt import (
AnswerForTest,
AnswerResult,
AttemptListItem,
AttemptListResponse,
AttemptResult,
AttemptStart,
AttemptStarted,
AttemptSubmitDto,
QuestionForTest,
QuestionResult,
)
# Временно: до Sprint 6 (авторизация) все попытки принадлежат гостю
GUEST_USER_ID = 1
GUEST_USER_NAME = "Гость"
router = APIRouter(prefix="/api/attempts", tags=["attempts"])
@router.post("", response_model=AttemptStarted, status_code=201)
async def start_attempt(data: AttemptStart, db: AsyncSession = Depends(get_db)):
"""Начать попытку прохождения теста. Возвращает вопросы в случайном порядке без правильных ответов."""
result = await db.execute(
select(Test)
.options(selectinload(Test.questions).selectinload(Question.answers))
.where(Test.id == data.test_id, Test.is_active == True)
)
test = result.scalar_one_or_none()
if not test:
raise HTTPException(status_code=404, detail="Тест не найден")
attempt = TestAttempt(test_id=test.id, status="in_progress", user_id=GUEST_USER_ID)
db.add(attempt)
await db.commit()
await db.refresh(attempt)
# Перемешиваем вопросы и ответы случайно
questions_shuffled = list(test.questions)
random.shuffle(questions_shuffled)
questions_for_test = []
for q in questions_shuffled:
correct_count = sum(1 for a in q.answers if a.is_correct)
answers_shuffled = list(q.answers)
random.shuffle(answers_shuffled)
questions_for_test.append(
QuestionForTest(
id=q.id,
text=q.text,
is_multiple=correct_count > 1,
answers=[AnswerForTest(id=a.id, text=a.text) for a in answers_shuffled],
)
)
return AttemptStarted(
id=attempt.id,
test_id=test.id,
test_title=test.title,
test_description=test.description,
started_at=attempt.started_at,
time_limit=test.time_limit,
allow_navigation_back=test.allow_navigation_back,
questions=questions_for_test,
)
@router.post("/{attempt_id}/submit", response_model=AttemptResult)
async def submit_attempt(
attempt_id: int, data: AttemptSubmitDto, db: AsyncSession = Depends(get_db)
):
"""Завершить попытку: сохранить ответы, подсчитать результат."""
result = await db.execute(
select(TestAttempt)
.where(TestAttempt.id == attempt_id, TestAttempt.status == "in_progress")
)
attempt = result.scalar_one_or_none()
if not attempt:
raise HTTPException(status_code=404, detail="Попытка не найдена или уже завершена")
# Загружаем тест с вопросами и ответами
test_result = await db.execute(
select(Test)
.options(selectinload(Test.questions).selectinload(Question.answers))
.where(Test.id == attempt.test_id)
)
test = test_result.scalar_one()
# Сохраняем выбранные ответы
submitted = {qa.question_id: set(qa.answer_ids) for qa in data.answers}
for qa in data.answers:
for answer_id in qa.answer_ids:
db.add(AttemptAnswer(
attempt_id=attempt.id,
question_id=qa.question_id,
answer_id=answer_id,
))
# Подсчёт результата
correct_count = 0
question_results = []
for question in test.questions:
correct_ids = {a.id for a in question.answers if a.is_correct}
selected_ids = submitted.get(question.id, set())
is_correct = selected_ids == correct_ids
if is_correct:
correct_count += 1
question_results.append(
QuestionResult(
id=question.id,
text=question.text,
is_answered_correctly=is_correct,
answers=[
AnswerResult(
id=a.id,
text=a.text,
is_correct=a.is_correct,
is_selected=a.id in selected_ids,
)
for a in question.answers
],
)
)
total = len(test.questions)
score = round((correct_count / total) * 100, 1) if total > 0 else 0.0
passed = score >= test.passing_score
finished_at = datetime.now(timezone.utc)
attempt.finished_at = finished_at
attempt.score = score
attempt.passed = passed
attempt.correct_count = correct_count
attempt.total_count = total
attempt.status = "finished"
await db.commit()
return AttemptResult(
id=attempt.id,
test_id=test.id,
test_title=test.title,
started_at=attempt.started_at,
finished_at=finished_at,
score=score,
passed=passed,
passing_score=test.passing_score,
correct_count=correct_count,
total_count=total,
questions=question_results,
)
@router.get("", response_model=AttemptListResponse)
async def list_attempts(
test_id: Optional[int] = Query(None),
date_from: Optional[datetime] = Query(None),
date_to: Optional[datetime] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
"""Трекер попыток: все завершённые попытки с фильтрацией и пагинацией."""
base = (
select(TestAttempt)
.options(selectinload(TestAttempt.test))
.where(TestAttempt.status == "finished")
)
if test_id is not None:
base = base.where(TestAttempt.test_id == test_id)
if date_from is not None:
base = base.where(TestAttempt.started_at >= date_from)
if date_to is not None:
base = base.where(TestAttempt.started_at <= date_to)
total = (await db.execute(select(func.count()).select_from(base.subquery()))).scalar_one()
rows_result = await db.execute(
base.order_by(TestAttempt.started_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
attempts_list = rows_result.scalars().all()
items = [
AttemptListItem(
id=a.id,
test_id=a.test_id,
test_title=a.test.title,
test_version=a.test.version,
user_id=a.user_id,
user_name=GUEST_USER_NAME, # TODO Sprint 6: заменить на JOIN с таблицей users
started_at=a.started_at,
finished_at=a.finished_at,
score=a.score,
correct_count=a.correct_count,
total_count=a.total_count,
passed=a.passed,
)
for a in attempts_list
]
return AttemptListResponse(items=items, total=total, page=page, page_size=page_size)
@router.get("/{attempt_id}/result", response_model=AttemptResult)
async def get_result(attempt_id: int, db: AsyncSession = Depends(get_db)):
"""Получить результат завершённой попытки."""
result = await db.execute(
select(TestAttempt)
.options(selectinload(TestAttempt.answers).selectinload(AttemptAnswer.answer))
.where(TestAttempt.id == attempt_id, TestAttempt.status == "finished")
)
attempt = result.scalar_one_or_none()
if not attempt:
raise HTTPException(status_code=404, detail="Результат не найден")
test_result = await db.execute(
select(Test)
.options(selectinload(Test.questions).selectinload(Question.answers))
.where(Test.id == attempt.test_id)
)
test = test_result.scalar_one()
# Восстанавливаем выбранные ответы из БД
selected_by_question: dict[int, set[int]] = {}
for aa in attempt.answers:
selected_by_question.setdefault(aa.question_id, set()).add(aa.answer_id)
question_results = []
for question in test.questions:
correct_ids = {a.id for a in question.answers if a.is_correct}
selected_ids = selected_by_question.get(question.id, set())
question_results.append(
QuestionResult(
id=question.id,
text=question.text,
is_answered_correctly=selected_ids == correct_ids,
answers=[
AnswerResult(
id=a.id,
text=a.text,
is_correct=a.is_correct,
is_selected=a.id in selected_ids,
)
for a in question.answers
],
)
)
return AttemptResult(
id=attempt.id,
test_id=test.id,
test_title=test.title,
started_at=attempt.started_at,
finished_at=attempt.finished_at,
score=attempt.score,
passed=attempt.passed,
passing_score=test.passing_score,
correct_count=attempt.correct_count,
total_count=attempt.total_count,
questions=question_results,
)