feat: Sprint 3 — test editing with versioning
Backend:
- migration 003: add parent_id to tests table
- PUT /api/tests/{id}: edit in place if no attempts, create new version otherwise
- GET /api/tests: show only latest versions (no successor)
Frontend:
- TestForm: extracted reusable form component
- TestCreate: refactored to use TestForm
- TestEdit: full edit mode with pre-populated form, version redirect on new version
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
"""test versioning
|
||||
|
||||
Revision ID: 003
|
||||
Revises: 002
|
||||
Create Date: 2026-03-21
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "003"
|
||||
down_revision: Union[str, None] = "002"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"tests",
|
||||
sa.Column("parent_id", sa.Integer(), sa.ForeignKey("tests.id"), nullable=True),
|
||||
)
|
||||
op.create_index("ix_tests_parent_id", "tests", ["parent_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_tests_parent_id", table_name="tests")
|
||||
op.drop_column("tests", "parent_id")
|
||||
@@ -1,21 +1,25 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.attempt import TestAttempt
|
||||
from app.models.test import Answer, Question, Test
|
||||
from app.schemas.test import TestCreate, TestListItem, TestOut
|
||||
from app.schemas.test import TestCreate, TestListItem, TestOut, TestUpdateResponse
|
||||
|
||||
router = APIRouter(prefix="/api/tests", tags=["tests"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[TestListItem])
|
||||
async def list_tests(db: AsyncSession = Depends(get_db)):
|
||||
# Показываем только последние версии: те, чей id не упоминается как parent_id
|
||||
parent_ids_subq = select(Test.parent_id).where(Test.parent_id.is_not(None))
|
||||
|
||||
result = await db.execute(
|
||||
select(Test)
|
||||
.options(selectinload(Test.questions))
|
||||
.where(Test.is_active == True)
|
||||
.where(Test.is_active == True, Test.id.not_in(parent_ids_subq))
|
||||
.order_by(Test.created_at.desc())
|
||||
)
|
||||
tests = result.scalars().all()
|
||||
@@ -68,10 +72,90 @@ async def create_test(data: TestCreate, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Перезагружаем с вложенными связями
|
||||
result = await db.execute(
|
||||
select(Test)
|
||||
.options(selectinload(Test.questions).selectinload(Question.answers))
|
||||
.where(Test.id == test.id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
@router.put("/{test_id}", response_model=TestUpdateResponse)
|
||||
async def update_test(test_id: int, data: TestCreate, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(Test)
|
||||
.where(Test.id == test_id, Test.is_active == True)
|
||||
)
|
||||
test = result.scalar_one_or_none()
|
||||
if not test:
|
||||
raise HTTPException(status_code=404, detail="Тест не найден")
|
||||
|
||||
# Проверяем, есть ли уже попытки прохождения
|
||||
attempts_count = await db.scalar(
|
||||
select(func.count()).select_from(TestAttempt).where(TestAttempt.test_id == test_id)
|
||||
)
|
||||
|
||||
if attempts_count == 0:
|
||||
# Редактируем на месте: обновляем поля, пересоздаём вопросы
|
||||
test.title = data.title
|
||||
test.description = data.description
|
||||
test.passing_score = data.passing_score
|
||||
test.time_limit = data.time_limit
|
||||
test.allow_navigation_back = data.allow_navigation_back
|
||||
|
||||
await db.execute(delete(Question).where(Question.test_id == test_id))
|
||||
await db.flush()
|
||||
|
||||
for order, q_data in enumerate(data.questions):
|
||||
question = Question(test_id=test.id, text=q_data.text, order=order)
|
||||
db.add(question)
|
||||
await db.flush()
|
||||
for a_data in q_data.answers:
|
||||
db.add(Answer(
|
||||
question_id=question.id,
|
||||
text=a_data.text,
|
||||
is_correct=a_data.is_correct,
|
||||
))
|
||||
|
||||
await db.commit()
|
||||
|
||||
result = await db.execute(
|
||||
select(Test)
|
||||
.options(selectinload(Test.questions).selectinload(Question.answers))
|
||||
.where(Test.id == test.id)
|
||||
)
|
||||
return {"test": result.scalar_one(), "is_new_version": False}
|
||||
|
||||
else:
|
||||
# Есть попытки — создаём новую версию
|
||||
new_test = Test(
|
||||
title=data.title,
|
||||
description=data.description,
|
||||
passing_score=data.passing_score,
|
||||
time_limit=data.time_limit,
|
||||
allow_navigation_back=data.allow_navigation_back,
|
||||
version=test.version + 1,
|
||||
parent_id=test.id,
|
||||
)
|
||||
db.add(new_test)
|
||||
await db.flush()
|
||||
|
||||
for order, q_data in enumerate(data.questions):
|
||||
question = Question(test_id=new_test.id, text=q_data.text, order=order)
|
||||
db.add(question)
|
||||
await db.flush()
|
||||
for a_data in q_data.answers:
|
||||
db.add(Answer(
|
||||
question_id=question.id,
|
||||
text=a_data.text,
|
||||
is_correct=a_data.is_correct,
|
||||
))
|
||||
|
||||
await db.commit()
|
||||
|
||||
result = await db.execute(
|
||||
select(Test)
|
||||
.options(selectinload(Test.questions).selectinload(Question.answers))
|
||||
.where(Test.id == new_test.id)
|
||||
)
|
||||
return {"test": result.scalar_one(), "is_new_version": True}
|
||||
|
||||
@@ -18,6 +18,9 @@ class Test(Base):
|
||||
allow_navigation_back: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
parent_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("tests.id"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
@@ -65,12 +65,18 @@ class TestOut(BaseModel):
|
||||
allow_navigation_back: bool
|
||||
is_active: bool
|
||||
version: int
|
||||
parent_id: Optional[int]
|
||||
created_at: datetime
|
||||
questions: list[QuestionOut] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TestUpdateResponse(BaseModel):
|
||||
test: TestOut
|
||||
is_new_version: bool
|
||||
|
||||
|
||||
class TestListItem(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
|
||||
Reference in New Issue
Block a user