Спринт 5: Трекер результатов

- Миграция 005: user_id в test_attempts (дефолт 1 = Гость)
- GET /api/attempts с фильтрами по тесту, дате и пагинацией
- Страница /tracker: таблица попыток, фильтры, пагинация
- Ссылка «Трекер» в шапке приложения

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aleksey Razorvin
2026-03-21 15:26:40 +05:00
parent 9a0b3ba92c
commit fc684e7c7d
11 changed files with 522 additions and 22 deletions
+64 -5
View File
@@ -1,8 +1,9 @@
import random
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -10,16 +11,22 @@ 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,
AnswerForTest,
AnswerResult,
QuestionForTest,
QuestionResult,
)
# Временно: до Sprint 6 (авторизация) все попытки принадлежат гостю
GUEST_USER_ID = 1
GUEST_USER_NAME = "Гость"
router = APIRouter(prefix="/api/attempts", tags=["attempts"])
@@ -36,7 +43,7 @@ async def start_attempt(data: AttemptStart, db: AsyncSession = Depends(get_db)):
if not test:
raise HTTPException(status_code=404, detail="Тест не найден")
attempt = TestAttempt(test_id=test.id, status="in_progress")
attempt = TestAttempt(test_id=test.id, status="in_progress", user_id=GUEST_USER_ID)
db.add(attempt)
await db.commit()
await db.refresh(attempt)
@@ -162,6 +169,58 @@ async def submit_attempt(
)
@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)):
"""Получить результат завершённой попытки."""
+1
View File
@@ -12,6 +12,7 @@ class TestAttempt(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
test_id: Mapped[int] = mapped_column(Integer, ForeignKey("tests.id"), nullable=False)
user_id: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
+26
View File
@@ -87,3 +87,29 @@ class AttemptResult(BaseModel):
questions: list[QuestionResult]
model_config = {"from_attributes": True}
# ── Трекер результатов ────────────────────────────────────────
class AttemptListItem(BaseModel):
id: int
test_id: int
test_title: str
test_version: int
user_id: int
user_name: str
started_at: datetime
finished_at: Optional[datetime]
score: Optional[float]
correct_count: Optional[int]
total_count: Optional[int]
passed: Optional[bool]
model_config = {"from_attributes": True}
class AttemptListResponse(BaseModel):
items: list[AttemptListItem]
total: int
page: int
page_size: int