Browse Source
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>
master
12 changed files with 973 additions and 4 deletions
@ -0,0 +1,97 @@ |
|||||||
|
# ШАГ 007 — Спринт 2: Прохождение теста + результат |
||||||
|
|
||||||
|
**Дата:** 2026-03-21 |
||||||
|
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Запрос |
||||||
|
|
||||||
|
> запускай реализацию спринта 2 |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Ответ |
||||||
|
|
||||||
|
Реализован Спринт 2: прохождение теста с таймером и навигацией, результат с разбором ошибок. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Новые файлы |
||||||
|
|
||||||
|
``` |
||||||
|
backend/app/models/attempt.py ← TestAttempt, AttemptAnswer |
||||||
|
backend/app/schemas/attempt.py ← схемы для старта, сдачи и результата |
||||||
|
backend/app/api/attempts.py ← 3 эндпоинта |
||||||
|
backend/alembic/versions/002_attempts.py ← миграция |
||||||
|
|
||||||
|
frontend/src/api/attempts.ts ← типы и запросы |
||||||
|
frontend/src/pages/TestTake/ ← страница прохождения теста |
||||||
|
frontend/src/pages/AttemptResult/ ← страница результата |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## API эндпоинты (новые) |
||||||
|
|
||||||
|
| Метод | URL | Описание | |
||||||
|
|-------|-----|----------| |
||||||
|
| POST | `/api/attempts` | Начать попытку → возвращает вопросы перемешанные, без правильных ответов | |
||||||
|
| POST | `/api/attempts/{id}/submit` | Сдать тест → подсчитать и вернуть результат | |
||||||
|
| GET | `/api/attempts/{id}/result` | Получить результат сохранённой попытки | |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Схема БД (добавлено) |
||||||
|
|
||||||
|
``` |
||||||
|
test_attempts |
||||||
|
id, test_id → tests.id, started_at, finished_at, |
||||||
|
score, passed, correct_count, total_count, status |
||||||
|
|
||||||
|
attempt_answers |
||||||
|
id, attempt_id → test_attempts.id, |
||||||
|
question_id → questions.id, answer_id → answers.id |
||||||
|
``` |
||||||
|
|
||||||
|
Одна строка `attempt_answers` = один выбранный вариант ответа. |
||||||
|
Для вопросов с несколькими правильными ответами — несколько строк. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Логика прохождения теста |
||||||
|
|
||||||
|
**Старт попытки:** |
||||||
|
- Создаётся запись `TestAttempt` со статусом `in_progress` |
||||||
|
- Вопросы и ответы внутри каждого вопроса перемешиваются случайно |
||||||
|
- Поле `is_correct` **не передаётся** на фронт — нельзя смошенничать через DevTools |
||||||
|
- Поле `is_multiple: bool` говорит фронту: показывать радио-кнопки или чекбоксы |
||||||
|
|
||||||
|
**Сдача теста:** |
||||||
|
- Фронт отправляет `[{ question_id, answer_ids[] }]` для каждого вопроса |
||||||
|
- Вопрос засчитывается правильным только если `selected_ids == correct_ids` (точное совпадение) |
||||||
|
- Балл = (правильных / всего) × 100 |
||||||
|
- Зачёт: балл ≥ порогу из теста |
||||||
|
|
||||||
|
**Разбор ошибок:** |
||||||
|
- Для каждого вопроса: `is_answered_correctly` |
||||||
|
- Для каждого варианта: `is_correct` + `is_selected` → фронт показывает что выбрал и что было правильно |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## UX прохождения теста |
||||||
|
|
||||||
|
- Вопросы по одному, прогресс-бар сверху |
||||||
|
- Таймер: если задан — обратный отсчёт, при `< 60 сек` — предупреждение, при `0` — автосабмит |
||||||
|
- Кнопка «Назад» заблокирована если `allow_navigation_back = false` |
||||||
|
- Чекбоксы для `is_multiple`, радио-кнопки для одиночного ответа |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Следующие шаги |
||||||
|
|
||||||
|
- [x] Спринт 1: Инфраструктура + Создание тестов |
||||||
|
- [x] Спринт 2: Прохождение теста + результат |
||||||
|
- [ ] Спринт 3: Трекер результатов |
||||||
|
- [ ] Спринт 4: Авторизация и роли |
||||||
|
- [ ] Спринт 5: Уведомления в MAX |
||||||
@ -0,0 +1,53 @@ |
|||||||
|
"""attempts |
||||||
|
|
||||||
|
Revision ID: 002 |
||||||
|
Revises: 001 |
||||||
|
Create Date: 2026-03-21 |
||||||
|
|
||||||
|
""" |
||||||
|
from typing import Sequence, Union |
||||||
|
|
||||||
|
import sqlalchemy as sa |
||||||
|
from alembic import op |
||||||
|
|
||||||
|
revision: str = "002" |
||||||
|
down_revision: Union[str, None] = "001" |
||||||
|
branch_labels: Union[str, Sequence[str], None] = None |
||||||
|
depends_on: Union[str, Sequence[str], None] = None |
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None: |
||||||
|
op.create_table( |
||||||
|
"test_attempts", |
||||||
|
sa.Column("id", sa.Integer(), nullable=False), |
||||||
|
sa.Column("test_id", sa.Integer(), sa.ForeignKey("tests.id"), nullable=False), |
||||||
|
sa.Column( |
||||||
|
"started_at", |
||||||
|
sa.DateTime(timezone=True), |
||||||
|
server_default=sa.text("now()"), |
||||||
|
nullable=False, |
||||||
|
), |
||||||
|
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True), |
||||||
|
sa.Column("score", sa.Float(), nullable=True), |
||||||
|
sa.Column("passed", sa.Boolean(), nullable=True), |
||||||
|
sa.Column("correct_count", sa.Integer(), nullable=True), |
||||||
|
sa.Column("total_count", sa.Integer(), nullable=True), |
||||||
|
sa.Column("status", sa.String(20), nullable=False, server_default="in_progress"), |
||||||
|
sa.PrimaryKeyConstraint("id"), |
||||||
|
) |
||||||
|
|
||||||
|
op.create_table( |
||||||
|
"attempt_answers", |
||||||
|
sa.Column("id", sa.Integer(), nullable=False), |
||||||
|
sa.Column( |
||||||
|
"attempt_id", sa.Integer(), sa.ForeignKey("test_attempts.id"), nullable=False |
||||||
|
), |
||||||
|
sa.Column("question_id", sa.Integer(), sa.ForeignKey("questions.id"), nullable=False), |
||||||
|
sa.Column("answer_id", sa.Integer(), sa.ForeignKey("answers.id"), nullable=False), |
||||||
|
sa.PrimaryKeyConstraint("id"), |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None: |
||||||
|
op.drop_table("attempt_answers") |
||||||
|
op.drop_table("test_attempts") |
||||||
@ -0,0 +1,223 @@ |
|||||||
|
import random |
||||||
|
from datetime import datetime, timezone |
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException |
||||||
|
from sqlalchemy import 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 ( |
||||||
|
AttemptResult, |
||||||
|
AttemptStart, |
||||||
|
AttemptStarted, |
||||||
|
AttemptSubmitDto, |
||||||
|
AnswerForTest, |
||||||
|
AnswerResult, |
||||||
|
QuestionForTest, |
||||||
|
QuestionResult, |
||||||
|
) |
||||||
|
|
||||||
|
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") |
||||||
|
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("/{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, |
||||||
|
) |
||||||
@ -0,0 +1,49 @@ |
|||||||
|
from datetime import datetime |
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String |
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship |
||||||
|
from sqlalchemy.sql import func |
||||||
|
|
||||||
|
from app.database import Base |
||||||
|
|
||||||
|
|
||||||
|
class TestAttempt(Base): |
||||||
|
__tablename__ = "test_attempts" |
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True) |
||||||
|
test_id: Mapped[int] = mapped_column(Integer, ForeignKey("tests.id"), nullable=False) |
||||||
|
started_at: Mapped[datetime] = mapped_column( |
||||||
|
DateTime(timezone=True), server_default=func.now() |
||||||
|
) |
||||||
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) |
||||||
|
score: Mapped[float | None] = mapped_column(Float, nullable=True) # процент 0–100 |
||||||
|
passed: Mapped[bool | None] = mapped_column(Boolean, nullable=True) |
||||||
|
correct_count: Mapped[int | None] = mapped_column(Integer, nullable=True) |
||||||
|
total_count: Mapped[int | None] = mapped_column(Integer, nullable=True) |
||||||
|
# in_progress | finished |
||||||
|
status: Mapped[str] = mapped_column(String(20), default="in_progress", nullable=False) |
||||||
|
|
||||||
|
test: Mapped["Test"] = relationship("Test") # type: ignore[name-defined] |
||||||
|
answers: Mapped[list["AttemptAnswer"]] = relationship( |
||||||
|
"AttemptAnswer", back_populates="attempt", cascade="all, delete-orphan" |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
class AttemptAnswer(Base): |
||||||
|
"""Одна запись = один выбранный вариант ответа сотрудника.""" |
||||||
|
|
||||||
|
__tablename__ = "attempt_answers" |
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True) |
||||||
|
attempt_id: Mapped[int] = mapped_column( |
||||||
|
Integer, ForeignKey("test_attempts.id"), nullable=False |
||||||
|
) |
||||||
|
question_id: Mapped[int] = mapped_column( |
||||||
|
Integer, ForeignKey("questions.id"), nullable=False |
||||||
|
) |
||||||
|
answer_id: Mapped[int] = mapped_column( |
||||||
|
Integer, ForeignKey("answers.id"), nullable=False |
||||||
|
) |
||||||
|
|
||||||
|
attempt: Mapped["TestAttempt"] = relationship("TestAttempt", back_populates="answers") |
||||||
|
answer: Mapped["Answer"] = relationship("Answer") # type: ignore[name-defined] |
||||||
@ -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} |
||||||
@ -0,0 +1,68 @@ |
|||||||
|
import client from './client' |
||||||
|
|
||||||
|
export interface AnswerForTest { |
||||||
|
id: number |
||||||
|
text: string |
||||||
|
} |
||||||
|
|
||||||
|
export interface QuestionForTest { |
||||||
|
id: number |
||||||
|
text: string |
||||||
|
is_multiple: boolean // true → показываем чекбоксы, false → радио-кнопки
|
||||||
|
answers: AnswerForTest[] |
||||||
|
} |
||||||
|
|
||||||
|
export interface AttemptStarted { |
||||||
|
id: number |
||||||
|
test_id: number |
||||||
|
test_title: string |
||||||
|
test_description: string | null |
||||||
|
started_at: string |
||||||
|
time_limit: number | null // минуты
|
||||||
|
allow_navigation_back: boolean |
||||||
|
questions: QuestionForTest[] |
||||||
|
} |
||||||
|
|
||||||
|
export interface QuestionAnswer { |
||||||
|
question_id: number |
||||||
|
answer_ids: number[] |
||||||
|
} |
||||||
|
|
||||||
|
export interface AnswerResult { |
||||||
|
id: number |
||||||
|
text: string |
||||||
|
is_correct: boolean |
||||||
|
is_selected: boolean |
||||||
|
} |
||||||
|
|
||||||
|
export interface QuestionResult { |
||||||
|
id: number |
||||||
|
text: string |
||||||
|
is_answered_correctly: boolean |
||||||
|
answers: AnswerResult[] |
||||||
|
} |
||||||
|
|
||||||
|
export interface AttemptResult { |
||||||
|
id: number |
||||||
|
test_id: number |
||||||
|
test_title: string |
||||||
|
started_at: string |
||||||
|
finished_at: string |
||||||
|
score: number |
||||||
|
passed: boolean |
||||||
|
passing_score: number |
||||||
|
correct_count: number |
||||||
|
total_count: number |
||||||
|
questions: QuestionResult[] |
||||||
|
} |
||||||
|
|
||||||
|
export const attemptsApi = { |
||||||
|
start: (test_id: number) => |
||||||
|
client.post<AttemptStarted>('/attempts', { test_id }), |
||||||
|
|
||||||
|
submit: (attempt_id: number, answers: QuestionAnswer[]) => |
||||||
|
client.post<AttemptResult>(`/attempts/${attempt_id}/submit`, { answers }), |
||||||
|
|
||||||
|
getResult: (attempt_id: number) => |
||||||
|
client.get<AttemptResult>(`/attempts/${attempt_id}/result`), |
||||||
|
} |
||||||
@ -0,0 +1,153 @@ |
|||||||
|
import { |
||||||
|
CheckCircleTwoTone, |
||||||
|
CloseCircleTwoTone, |
||||||
|
MinusCircleOutlined, |
||||||
|
TrophyOutlined, |
||||||
|
} from '@ant-design/icons' |
||||||
|
import { useQuery } from '@tanstack/react-query' |
||||||
|
import { Button, Card, Col, Divider, List, Result, Row, Space, Spin, Tag, Typography } from 'antd' |
||||||
|
import { useNavigate, useParams } from 'react-router-dom' |
||||||
|
|
||||||
|
import { AnswerResult, attemptsApi } from '../../api/attempts' |
||||||
|
|
||||||
|
const { Title, Text } = Typography |
||||||
|
|
||||||
|
export default function AttemptResult() { |
||||||
|
const { attemptId } = useParams<{ attemptId: string }>() |
||||||
|
const navigate = useNavigate() |
||||||
|
|
||||||
|
const { data: result, isLoading } = useQuery({ |
||||||
|
queryKey: ['attempts', attemptId, 'result'], |
||||||
|
queryFn: () => attemptsApi.getResult(Number(attemptId)).then((r) => r.data), |
||||||
|
}) |
||||||
|
|
||||||
|
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '48px auto' }} /> |
||||||
|
if (!result) return null |
||||||
|
|
||||||
|
const duration = Math.round( |
||||||
|
(new Date(result.finished_at).getTime() - new Date(result.started_at).getTime()) / 1000, |
||||||
|
) |
||||||
|
const minutes = Math.floor(duration / 60) |
||||||
|
const seconds = duration % 60 |
||||||
|
|
||||||
|
return ( |
||||||
|
<div style={{ maxWidth: 800, margin: '0 auto', padding: 24 }}> |
||||||
|
|
||||||
|
{/* Итог */} |
||||||
|
<Result |
||||||
|
icon={ |
||||||
|
result.passed ? ( |
||||||
|
<TrophyOutlined style={{ color: '#52c41a' }} /> |
||||||
|
) : ( |
||||||
|
<CloseCircleTwoTone twoToneColor="#ff4d4f" /> |
||||||
|
) |
||||||
|
} |
||||||
|
status={result.passed ? 'success' : 'error'} |
||||||
|
title={result.passed ? 'Тест сдан!' : 'Тест не сдан'} |
||||||
|
subTitle={result.test_title} |
||||||
|
/> |
||||||
|
|
||||||
|
{/* Статистика */} |
||||||
|
<Card style={{ marginBottom: 24 }}> |
||||||
|
<Row gutter={32} justify="center" style={{ textAlign: 'center' }}> |
||||||
|
<Col> |
||||||
|
<Title level={1} style={{ margin: 0, color: result.passed ? '#52c41a' : '#ff4d4f' }}> |
||||||
|
{result.score}% |
||||||
|
</Title> |
||||||
|
<Text type="secondary">Результат</Text> |
||||||
|
</Col> |
||||||
|
<Col> |
||||||
|
<Title level={1} style={{ margin: 0 }}> |
||||||
|
{result.correct_count}/{result.total_count} |
||||||
|
</Title> |
||||||
|
<Text type="secondary">Правильных ответов</Text> |
||||||
|
</Col> |
||||||
|
<Col> |
||||||
|
<Title level={1} style={{ margin: 0 }}> |
||||||
|
{result.passing_score}% |
||||||
|
</Title> |
||||||
|
<Text type="secondary">Порог зачёта</Text> |
||||||
|
</Col> |
||||||
|
<Col> |
||||||
|
<Title level={1} style={{ margin: 0 }}> |
||||||
|
{minutes > 0 ? `${minutes}м ` : ''}{seconds}с |
||||||
|
</Title> |
||||||
|
<Text type="secondary">Время</Text> |
||||||
|
</Col> |
||||||
|
</Row> |
||||||
|
</Card> |
||||||
|
|
||||||
|
{/* Разбор ошибок */} |
||||||
|
<Title level={3}>Разбор ответов</Title> |
||||||
|
|
||||||
|
{result.questions.map((question, index) => ( |
||||||
|
<Card |
||||||
|
key={question.id} |
||||||
|
style={{ |
||||||
|
marginBottom: 12, |
||||||
|
borderColor: question.is_answered_correctly ? '#b7eb8f' : '#ffccc7', |
||||||
|
}} |
||||||
|
title={ |
||||||
|
<Space> |
||||||
|
{question.is_answered_correctly ? ( |
||||||
|
<CheckCircleTwoTone twoToneColor="#52c41a" /> |
||||||
|
) : ( |
||||||
|
<CloseCircleTwoTone twoToneColor="#ff4d4f" /> |
||||||
|
)} |
||||||
|
<Text strong> |
||||||
|
{index + 1}. {question.text} |
||||||
|
</Text> |
||||||
|
</Space> |
||||||
|
} |
||||||
|
> |
||||||
|
<List |
||||||
|
dataSource={question.answers} |
||||||
|
renderItem={(answer: AnswerResult) => { |
||||||
|
const icon = answer.is_correct ? ( |
||||||
|
<CheckCircleTwoTone twoToneColor="#52c41a" /> |
||||||
|
) : answer.is_selected ? ( |
||||||
|
<CloseCircleTwoTone twoToneColor="#ff4d4f" /> |
||||||
|
) : ( |
||||||
|
<MinusCircleOutlined style={{ color: '#d9d9d9' }} /> |
||||||
|
) |
||||||
|
|
||||||
|
return ( |
||||||
|
<List.Item style={{ padding: '4px 0' }}> |
||||||
|
<Space> |
||||||
|
{icon} |
||||||
|
<Text |
||||||
|
style={{ |
||||||
|
fontWeight: answer.is_correct ? 600 : 400, |
||||||
|
color: answer.is_selected && !answer.is_correct ? '#ff4d4f' : undefined, |
||||||
|
}} |
||||||
|
> |
||||||
|
{answer.text} |
||||||
|
</Text> |
||||||
|
{answer.is_selected && answer.is_correct && ( |
||||||
|
<Tag color="green">ваш ответ ✓</Tag> |
||||||
|
)} |
||||||
|
{answer.is_selected && !answer.is_correct && ( |
||||||
|
<Tag color="red">ваш ответ ✗</Tag> |
||||||
|
)} |
||||||
|
{!answer.is_selected && answer.is_correct && ( |
||||||
|
<Tag color="green">правильный ответ</Tag> |
||||||
|
)} |
||||||
|
</Space> |
||||||
|
</List.Item> |
||||||
|
) |
||||||
|
}} |
||||||
|
/> |
||||||
|
</Card> |
||||||
|
))} |
||||||
|
|
||||||
|
<Divider /> |
||||||
|
|
||||||
|
<Space> |
||||||
|
<Button onClick={() => navigate('/')}>К списку тестов</Button> |
||||||
|
<Button type="primary" onClick={() => navigate(`/tests/${result.test_id}`)}> |
||||||
|
Страница теста |
||||||
|
</Button> |
||||||
|
</Space> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,225 @@ |
|||||||
|
import { useEffect, useRef, useState } from 'react' |
||||||
|
import { useNavigate, useParams } from 'react-router-dom' |
||||||
|
import { |
||||||
|
Alert, |
||||||
|
Button, |
||||||
|
Card, |
||||||
|
Checkbox, |
||||||
|
Progress, |
||||||
|
Radio, |
||||||
|
Space, |
||||||
|
Spin, |
||||||
|
Tag, |
||||||
|
Typography, |
||||||
|
message, |
||||||
|
} from 'antd' |
||||||
|
import { ArrowLeftOutlined, ArrowRightOutlined, SendOutlined } from '@ant-design/icons' |
||||||
|
|
||||||
|
import { AttemptStarted, QuestionAnswer, attemptsApi } from '../../api/attempts' |
||||||
|
|
||||||
|
const { Title, Text } = Typography |
||||||
|
|
||||||
|
export default function TestTake() { |
||||||
|
const { testId } = useParams<{ testId: string }>() |
||||||
|
const navigate = useNavigate() |
||||||
|
|
||||||
|
const [attempt, setAttempt] = useState<AttemptStarted | null>(null) |
||||||
|
const [loading, setLoading] = useState(true) |
||||||
|
const [submitting, setSubmitting] = useState(false) |
||||||
|
const [currentIndex, setCurrentIndex] = useState(0) |
||||||
|
// answers: questionId → выбранные answerId[]
|
||||||
|
const [answers, setAnswers] = useState<Map<number, number[]>>(new Map()) |
||||||
|
const [timeLeft, setTimeLeft] = useState<number | null>(null) |
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null) |
||||||
|
|
||||||
|
// Стартуем попытку при монтировании
|
||||||
|
useEffect(() => { |
||||||
|
attemptsApi |
||||||
|
.start(Number(testId)) |
||||||
|
.then((r) => { |
||||||
|
setAttempt(r.data) |
||||||
|
if (r.data.time_limit) { |
||||||
|
setTimeLeft(r.data.time_limit * 60) |
||||||
|
} |
||||||
|
}) |
||||||
|
.catch(() => message.error('Не удалось загрузить тест')) |
||||||
|
.finally(() => setLoading(false)) |
||||||
|
}, [testId]) |
||||||
|
|
||||||
|
// Таймер
|
||||||
|
useEffect(() => { |
||||||
|
if (timeLeft === null) return |
||||||
|
|
||||||
|
timerRef.current = setInterval(() => { |
||||||
|
setTimeLeft((prev) => { |
||||||
|
if (prev === null || prev <= 1) { |
||||||
|
clearInterval(timerRef.current!) |
||||||
|
handleSubmit() |
||||||
|
return 0 |
||||||
|
} |
||||||
|
return prev - 1 |
||||||
|
}) |
||||||
|
}, 1000) |
||||||
|
|
||||||
|
return () => clearInterval(timerRef.current!) |
||||||
|
}, [timeLeft !== null]) // запускаем один раз когда timeLeft появился
|
||||||
|
|
||||||
|
const handleSubmit = async () => { |
||||||
|
if (!attempt || submitting) return |
||||||
|
clearInterval(timerRef.current!) |
||||||
|
setSubmitting(true) |
||||||
|
|
||||||
|
const payload: QuestionAnswer[] = attempt.questions.map((q) => ({ |
||||||
|
question_id: q.id, |
||||||
|
answer_ids: answers.get(q.id) ?? [], |
||||||
|
})) |
||||||
|
|
||||||
|
try { |
||||||
|
await attemptsApi.submit(attempt.id, payload) |
||||||
|
navigate(`/attempts/${attempt.id}/result`) |
||||||
|
} catch { |
||||||
|
message.error('Ошибка при отправке теста') |
||||||
|
setSubmitting(false) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const handleAnswer = (questionId: number, answerId: number, isMultiple: boolean) => { |
||||||
|
setAnswers((prev) => { |
||||||
|
const next = new Map(prev) |
||||||
|
if (isMultiple) { |
||||||
|
const current = next.get(questionId) ?? [] |
||||||
|
next.set( |
||||||
|
questionId, |
||||||
|
current.includes(answerId) |
||||||
|
? current.filter((id) => id !== answerId) |
||||||
|
: [...current, answerId], |
||||||
|
) |
||||||
|
} else { |
||||||
|
next.set(questionId, [answerId]) |
||||||
|
} |
||||||
|
return next |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
const formatTime = (seconds: number) => { |
||||||
|
const m = Math.floor(seconds / 60) |
||||||
|
const s = seconds % 60 |
||||||
|
return `${m}:${s.toString().padStart(2, '0')}` |
||||||
|
} |
||||||
|
|
||||||
|
if (loading) return <Spin size="large" style={{ display: 'block', margin: '48px auto' }} /> |
||||||
|
if (!attempt) return null |
||||||
|
|
||||||
|
const question = attempt.questions[currentIndex] |
||||||
|
const selectedIds = answers.get(question.id) ?? [] |
||||||
|
const total = attempt.questions.length |
||||||
|
const isLast = currentIndex === total - 1 |
||||||
|
const isTimeCritical = timeLeft !== null && timeLeft < 60 |
||||||
|
|
||||||
|
return ( |
||||||
|
<div style={{ maxWidth: 720, margin: '0 auto', padding: 24 }}> |
||||||
|
|
||||||
|
{/* Шапка */} |
||||||
|
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}> |
||||||
|
<Title level={3} style={{ margin: 0 }}> |
||||||
|
{attempt.test_title} |
||||||
|
</Title> |
||||||
|
{timeLeft !== null && ( |
||||||
|
<Tag color={isTimeCritical ? 'red' : 'blue'} style={{ fontSize: 16, padding: '4px 12px' }}> |
||||||
|
⏱ {formatTime(timeLeft)} |
||||||
|
</Tag> |
||||||
|
)} |
||||||
|
</Space> |
||||||
|
|
||||||
|
{/* Прогресс */} |
||||||
|
<Progress |
||||||
|
percent={Math.round(((currentIndex + 1) / total) * 100)} |
||||||
|
format={() => `${currentIndex + 1} / ${total}`} |
||||||
|
style={{ marginBottom: 16 }} |
||||||
|
/> |
||||||
|
|
||||||
|
{isTimeCritical && ( |
||||||
|
<Alert |
||||||
|
message="Осталось меньше минуты!" |
||||||
|
type="warning" |
||||||
|
showIcon |
||||||
|
style={{ marginBottom: 16 }} |
||||||
|
/> |
||||||
|
)} |
||||||
|
|
||||||
|
{/* Вопрос */} |
||||||
|
<Card |
||||||
|
title={ |
||||||
|
<Text strong style={{ fontSize: 16 }}> |
||||||
|
Вопрос {currentIndex + 1} |
||||||
|
</Text> |
||||||
|
} |
||||||
|
style={{ marginBottom: 24 }} |
||||||
|
> |
||||||
|
<Text style={{ fontSize: 15, display: 'block', marginBottom: 20 }}> |
||||||
|
{question.text} |
||||||
|
</Text> |
||||||
|
|
||||||
|
{question.is_multiple && ( |
||||||
|
<Text type="secondary" style={{ display: 'block', marginBottom: 12 }}> |
||||||
|
Выберите все правильные варианты |
||||||
|
</Text> |
||||||
|
)} |
||||||
|
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size={10}> |
||||||
|
{question.answers.map((answer) => |
||||||
|
question.is_multiple ? ( |
||||||
|
<Checkbox |
||||||
|
key={answer.id} |
||||||
|
checked={selectedIds.includes(answer.id)} |
||||||
|
onChange={() => handleAnswer(question.id, answer.id, true)} |
||||||
|
style={{ fontSize: 14 }} |
||||||
|
> |
||||||
|
{answer.text} |
||||||
|
</Checkbox> |
||||||
|
) : ( |
||||||
|
<Radio |
||||||
|
key={answer.id} |
||||||
|
checked={selectedIds.includes(answer.id)} |
||||||
|
onChange={() => handleAnswer(question.id, answer.id, false)} |
||||||
|
style={{ fontSize: 14 }} |
||||||
|
> |
||||||
|
{answer.text} |
||||||
|
</Radio> |
||||||
|
), |
||||||
|
)} |
||||||
|
</Space> |
||||||
|
</Card> |
||||||
|
|
||||||
|
{/* Навигация */} |
||||||
|
<Space style={{ width: '100%', justifyContent: 'space-between' }}> |
||||||
|
<Button |
||||||
|
icon={<ArrowLeftOutlined />} |
||||||
|
onClick={() => setCurrentIndex((i) => i - 1)} |
||||||
|
disabled={currentIndex === 0 || !attempt.allow_navigation_back} |
||||||
|
> |
||||||
|
Назад |
||||||
|
</Button> |
||||||
|
|
||||||
|
{isLast ? ( |
||||||
|
<Button |
||||||
|
type="primary" |
||||||
|
icon={<SendOutlined />} |
||||||
|
loading={submitting} |
||||||
|
onClick={handleSubmit} |
||||||
|
> |
||||||
|
Завершить тест |
||||||
|
</Button> |
||||||
|
) : ( |
||||||
|
<Button |
||||||
|
type="primary" |
||||||
|
icon={<ArrowRightOutlined />} |
||||||
|
onClick={() => setCurrentIndex((i) => i + 1)} |
||||||
|
> |
||||||
|
Далее |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
</Space> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
Loading…
Reference in new issue