feat: Sprint 2 — test taking + results

Backend:
- Models: TestAttempt, AttemptAnswer (migration 002)
- POST /api/attempts: start attempt, shuffle questions/answers,
  hide is_correct, expose is_multiple for UI hints
- POST /api/attempts/{id}/submit: save answers, calculate score,
  strict matching (selected == correct), return full result
- GET /api/attempts/{id}/result: fetch saved result
- Register attempts router in main.py

Frontend:
- api/attempts.ts: types + API functions
- TestTake page: one question at a time, progress bar, timer
  with auto-submit, back navigation controlled by test setting,
  radio/checkbox based on is_multiple
- AttemptResult page: score, pass/fail, per-question breakdown
  with correct/selected/missed answer highlighting
- App.tsx: add /tests/:testId/take and /attempts/:id/result routes
- TestDetail: add "Пройти тест" button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aleksey Razorvin
2026-03-21 12:53:11 +05:00
parent 5551202d6f
commit d5f6abb5ad
12 changed files with 973 additions and 4 deletions
+89
View File
@@ -0,0 +1,89 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
# ── Начало попытки ──────────────────────────────────────────
class AttemptStart(BaseModel):
test_id: int
class AnswerForTest(BaseModel):
"""Вариант ответа без поля is_correct — не раскрываем правильные ответы."""
id: int
text: str
model_config = {"from_attributes": True}
class QuestionForTest(BaseModel):
id: int
text: str
is_multiple: bool # True = несколько правильных ответов → показываем чекбоксы
answers: list[AnswerForTest] # перемешаны случайно
model_config = {"from_attributes": True}
class AttemptStarted(BaseModel):
"""Возвращается после старта попытки."""
id: int
test_id: int
test_title: str
test_description: Optional[str]
started_at: datetime
time_limit: Optional[int] # минуты, из теста
allow_navigation_back: bool # из теста
questions: list[QuestionForTest] # перемешаны случайно
model_config = {"from_attributes": True}
# ── Сдача попытки ────────────────────────────────────────────
class QuestionAnswer(BaseModel):
"""Ответы сотрудника на один вопрос."""
question_id: int
answer_ids: list[int] # выбранные варианты (может быть пустым)
class AttemptSubmitDto(BaseModel):
answers: list[QuestionAnswer]
# ── Результат ────────────────────────────────────────────────
class AnswerResult(BaseModel):
id: int
text: str
is_correct: bool # правильный ли ответ вообще
is_selected: bool # выбрал ли его сотрудник
model_config = {"from_attributes": True}
class QuestionResult(BaseModel):
id: int
text: str
is_answered_correctly: bool # вся комбинация ответов верна
answers: list[AnswerResult]
model_config = {"from_attributes": True}
class AttemptResult(BaseModel):
id: int
test_id: int
test_title: str
started_at: datetime
finished_at: datetime
score: float # процент правильных ответов
passed: bool # преодолён ли порог зачёта
passing_score: int # порог из теста
correct_count: int
total_count: int
questions: list[QuestionResult]
model_config = {"from_attributes": True}