From b2a3bda01b691a80606290e453f28f6aeae60f1d Mon Sep 17 00:00:00 2001 From: Aleksey Razorvin <> Date: Sat, 21 Mar 2026 13:28:06 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20Sprint=203=20=E2=80=94=20test=20editing?= =?UTF-8?q?=20with=20versioning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../alembic/versions/003_test_versioning.py | 29 ++ backend/app/api/tests.py | 92 ++++++- backend/app/models/test.py | 3 + backend/app/schemas/test.py | 6 + frontend/src/api/tests.ts | 8 + frontend/src/components/TestForm/index.tsx | 254 +++++++++++++++++ frontend/src/pages/TestCreate/index.tsx | 256 +----------------- frontend/src/pages/TestEdit/index.tsx | 71 ++++- 8 files changed, 464 insertions(+), 255 deletions(-) create mode 100644 backend/alembic/versions/003_test_versioning.py create mode 100644 frontend/src/components/TestForm/index.tsx diff --git a/backend/alembic/versions/003_test_versioning.py b/backend/alembic/versions/003_test_versioning.py new file mode 100644 index 0000000..bf5d8e2 --- /dev/null +++ b/backend/alembic/versions/003_test_versioning.py @@ -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") diff --git a/backend/app/api/tests.py b/backend/app/api/tests.py index 7ae2358..4361bb6 100644 --- a/backend/app/api/tests.py +++ b/backend/app/api/tests.py @@ -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} diff --git a/backend/app/models/test.py b/backend/app/models/test.py index 259fec8..4fb5a0c 100644 --- a/backend/app/models/test.py +++ b/backend/app/models/test.py @@ -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() ) diff --git a/backend/app/schemas/test.py b/backend/app/schemas/test.py index fef4ed8..adf8739 100644 --- a/backend/app/schemas/test.py +++ b/backend/app/schemas/test.py @@ -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 diff --git a/frontend/src/api/tests.ts b/frontend/src/api/tests.ts index d32399e..e4189e8 100644 --- a/frontend/src/api/tests.ts +++ b/frontend/src/api/tests.ts @@ -22,10 +22,16 @@ export interface Test { allow_navigation_back: boolean is_active: boolean version: number + parent_id: number | null created_at: string questions: Question[] } +export interface UpdateTestResponse { + test: Test + is_new_version: boolean +} + export interface TestListItem { id: number title: string @@ -61,4 +67,6 @@ export const testsApi = { list: () => client.get('/tests'), get: (id: number) => client.get(`/tests/${id}`), create: (data: CreateTestDto) => client.post('/tests', data), + update: (id: number, data: CreateTestDto) => + client.put(`/tests/${id}`, data), } diff --git a/frontend/src/components/TestForm/index.tsx b/frontend/src/components/TestForm/index.tsx new file mode 100644 index 0000000..4c20b22 --- /dev/null +++ b/frontend/src/components/TestForm/index.tsx @@ -0,0 +1,254 @@ +import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons' +import { + Button, + Card, + Checkbox, + Form, + Input, + InputNumber, + Space, + Switch, + Typography, +} from 'antd' + +const { Title } = Typography + +const EMPTY_ANSWER = { text: '', is_correct: false } +const EMPTY_QUESTION = { text: '', answers: [EMPTY_ANSWER, EMPTY_ANSWER, EMPTY_ANSWER] } + +export interface TestFormValues { + title: string + description?: string + passing_score: number + has_timer: boolean + time_limit?: number + allow_navigation_back: boolean + questions: { text: string; answers: { text: string; is_correct: boolean }[] }[] +} + +interface TestFormProps { + heading: string + initialValues?: Partial + onSubmit: (values: TestFormValues) => void + isPending: boolean + submitLabel: string + onCancel: () => void +} + +export default function TestForm({ + heading, + initialValues, + onSubmit, + isPending, + submitLabel, + onCancel, +}: TestFormProps) { + const [form] = Form.useForm() + + const defaultValues: Partial = { + allow_navigation_back: true, + has_timer: false, + passing_score: 70, + questions: Array(7).fill(null).map(() => ({ ...EMPTY_QUESTION })), + ...initialValues, + } + + return ( +
+ {heading} + +
+ {/* ── Основные настройки ── */} + + + + + + + + + + + + + + + + + + + prev.has_timer !== cur.has_timer} + > + {({ getFieldValue }) => + getFieldValue('has_timer') ? ( + + + + ) : ( + без ограничения + ) + } + + + + + + + + + + {/* ── Вопросы ── */} + { + if (!questions || questions.length < 7) { + return Promise.reject(new Error('Минимум 7 вопросов')) + } + }, + }, + ]} + > + {(questionFields, { add: addQuestion, remove: removeQuestion }, { errors }) => ( + <> + {questionFields.map(({ key, name: qName }, index) => ( + 7 ? ( + removeQuestion(qName)} + /> + ) : null + } + style={{ marginBottom: 16 }} + > + + + + + { + if (!answers || answers.length < 3) { + return Promise.reject(new Error('Минимум 3 варианта ответа')) + } + if (!answers.some((a: { is_correct: boolean }) => a?.is_correct)) { + return Promise.reject( + new Error('Отметьте хотя бы один правильный ответ'), + ) + } + }, + }, + ]} + > + {( + answerFields, + { add: addAnswer, remove: removeAnswer }, + { errors: answerErrors }, + ) => ( + <> + {answerFields.map(({ key: ak, name: aName }) => ( + + + + + + + + + + {answerFields.length > 3 && ( + removeAnswer(aName)} + /> + )} + + ))} + + + + + + )} + + + ))} + + + + + + )} + + + + + + + + +
+
+ ) +} diff --git a/frontend/src/pages/TestCreate/index.tsx b/frontend/src/pages/TestCreate/index.tsx index 1bb11c2..a9754fe 100644 --- a/frontend/src/pages/TestCreate/index.tsx +++ b/frontend/src/pages/TestCreate/index.tsx @@ -1,33 +1,11 @@ -import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons' import { useMutation, useQueryClient } from '@tanstack/react-query' -import { - Button, - Card, - Checkbox, - Form, - Input, - InputNumber, - Space, - Switch, - Typography, - message, -} from 'antd' +import { message } from 'antd' import { useNavigate } from 'react-router-dom' import { CreateTestDto, testsApi } from '../../api/tests' - -const { Title } = Typography - -// Начальные данные: 7 пустых вопросов с 3 вариантами ответов каждый -const EMPTY_ANSWER = { text: '', is_correct: false } -const EMPTY_QUESTION = { - text: '', - answers: [EMPTY_ANSWER, EMPTY_ANSWER, EMPTY_ANSWER], -} -const INITIAL_QUESTIONS = Array(7).fill(null).map(() => ({ ...EMPTY_QUESTION })) +import TestForm, { TestFormValues } from '../../components/TestForm' export default function TestCreate() { - const [form] = Form.useForm() const navigate = useNavigate() const queryClient = useQueryClient() @@ -44,15 +22,7 @@ export default function TestCreate() { }, }) - const onFinish = (values: { - title: string - description?: string - passing_score: number - has_timer: boolean - time_limit?: number - allow_navigation_back: boolean - questions: { text: string; answers: { text: string; is_correct: boolean }[] }[] - }) => { + const onSubmit = (values: TestFormValues) => { createTest({ title: values.title, description: values.description, @@ -64,218 +34,12 @@ export default function TestCreate() { } return ( -
- Создание теста - -
- {/* ── Основные настройки ── */} - - - - - - - - - - - - - - {/* Таймер: переключатель + поле минут */} - - - - - - prev.has_timer !== cur.has_timer} - > - {({ getFieldValue }) => - getFieldValue('has_timer') ? ( - - - - ) : ( - без ограничения - ) - } - - - - - - - - - - {/* ── Вопросы ── */} - { - if (!questions || questions.length < 7) { - return Promise.reject(new Error('Минимум 7 вопросов')) - } - }, - }, - ]} - > - {(questionFields, { add: addQuestion, remove: removeQuestion }, { errors }) => ( - <> - {questionFields.map(({ key, name: qName }, index) => ( - 7 ? ( - removeQuestion(qName)} - /> - ) : null - } - style={{ marginBottom: 16 }} - > - - - - - {/* ── Варианты ответов ── */} - { - if (!answers || answers.length < 3) { - return Promise.reject(new Error('Минимум 3 варианта ответа')) - } - if (!answers.some((a: { is_correct: boolean }) => a?.is_correct)) { - return Promise.reject( - new Error('Отметьте хотя бы один правильный ответ'), - ) - } - }, - }, - ]} - > - {( - answerFields, - { add: addAnswer, remove: removeAnswer }, - { errors: answerErrors }, - ) => ( - <> - {answerFields.map(({ key: ak, name: aName }) => ( - - {/* Чекбокс «правильный» */} - - - - - {/* Текст ответа */} - - - - - {answerFields.length > 3 && ( - removeAnswer(aName)} - /> - )} - - ))} - - - - - - )} - - - ))} - - - - - - )} - - - - - - - - -
-
+ navigate('/')} + /> ) } diff --git a/frontend/src/pages/TestEdit/index.tsx b/frontend/src/pages/TestEdit/index.tsx index af03f13..576494b 100644 --- a/frontend/src/pages/TestEdit/index.tsx +++ b/frontend/src/pages/TestEdit/index.tsx @@ -5,29 +5,91 @@ import { EditOutlined, PlayCircleOutlined, } from '@ant-design/icons' -import { useQuery } from '@tanstack/react-query' -import { Alert, Button, Card, Descriptions, List, Space, Spin, Tag, Typography } from 'antd' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Alert, Button, Card, Descriptions, List, Space, Spin, Tag, Typography, message } from 'antd' +import { useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' -import { Answer, testsApi } from '../../api/tests' +import { Answer, CreateTestDto, testsApi } from '../../api/tests' +import TestForm, { TestFormValues } from '../../components/TestForm' const { Title, Text } = Typography export default function TestEdit() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() + const queryClient = useQueryClient() + const [editMode, setEditMode] = useState(false) const { data: test, isLoading } = useQuery({ queryKey: ['tests', id], queryFn: () => testsApi.get(Number(id)).then((r) => r.data), }) + const { mutate: updateTest, isPending } = useMutation({ + mutationFn: (data: CreateTestDto) => testsApi.update(Number(id), data).then((r) => r.data), + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: ['tests'] }) + if (result.is_new_version) { + message.success(`Создана новая версия теста (v${result.test.version})`) + navigate(`/tests/${result.test.id}/edit`) + } else { + message.success('Тест обновлён') + queryClient.invalidateQueries({ queryKey: ['tests', id] }) + setEditMode(false) + } + }, + onError: (error: unknown) => { + const err = error as { response?: { data?: { detail?: string } } } + message.error(err.response?.data?.detail || 'Ошибка при сохранении теста') + }, + }) + if (isLoading) { return } if (!test) return null + // Режим редактирования — показываем форму с предзаполненными данными + if (editMode) { + const initialValues: TestFormValues = { + title: test.title, + description: test.description ?? undefined, + passing_score: test.passing_score, + has_timer: test.time_limit !== null, + time_limit: test.time_limit ?? undefined, + allow_navigation_back: test.allow_navigation_back, + questions: test.questions.map((q) => ({ + text: q.text, + answers: q.answers.map((a) => ({ text: a.text, is_correct: a.is_correct })), + })), + } + + const onSubmit = (values: TestFormValues) => { + updateTest({ + title: values.title, + description: values.description, + passing_score: values.passing_score, + time_limit: values.has_timer ? values.time_limit : undefined, + allow_navigation_back: values.allow_navigation_back ?? true, + questions: values.questions, + }) + } + + return ( + setEditMode(false)} + /> + ) + } + + // Режим просмотра (вид автора) return (
@@ -37,8 +99,7 @@ export default function TestEdit() {