Спринт 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:
@@ -0,0 +1,31 @@
|
||||
"""005_attempt_user
|
||||
|
||||
Revision ID: 005
|
||||
Revises: 004
|
||||
Create Date: 2026-03-21
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "005"
|
||||
down_revision: Union[str, None] = "004"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"test_attempts",
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="1",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("test_attempts", "user_id")
|
||||
@@ -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)):
|
||||
"""Получить результат завершённой попытки."""
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user