Browse Source
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>
master
8 changed files with 464 additions and 255 deletions
@ -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") |
||||||
@ -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<TestFormValues> |
||||||
|
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<TestFormValues>() |
||||||
|
|
||||||
|
const defaultValues: Partial<TestFormValues> = { |
||||||
|
allow_navigation_back: true, |
||||||
|
has_timer: false, |
||||||
|
passing_score: 70, |
||||||
|
questions: Array(7).fill(null).map(() => ({ ...EMPTY_QUESTION })), |
||||||
|
...initialValues, |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div style={{ maxWidth: 820, margin: '0 auto', padding: 24 }}> |
||||||
|
<Title level={2}>{heading}</Title> |
||||||
|
|
||||||
|
<Form form={form} layout="vertical" onFinish={onSubmit} initialValues={defaultValues}> |
||||||
|
{/* ── Основные настройки ── */} |
||||||
|
<Card title="Основные настройки" style={{ marginBottom: 16 }}> |
||||||
|
<Form.Item |
||||||
|
name="title" |
||||||
|
label="Название теста" |
||||||
|
rules={[{ required: true, message: 'Введите название теста' }]} |
||||||
|
> |
||||||
|
<Input placeholder="Например: Пожарная безопасность 2026" /> |
||||||
|
</Form.Item> |
||||||
|
|
||||||
|
<Form.Item name="description" label="Описание (необязательно)"> |
||||||
|
<Input.TextArea rows={2} placeholder="Краткое описание теста" /> |
||||||
|
</Form.Item> |
||||||
|
|
||||||
|
<Form.Item |
||||||
|
name="passing_score" |
||||||
|
label="Порог зачёта" |
||||||
|
rules={[{ required: true, message: 'Укажите порог' }]} |
||||||
|
> |
||||||
|
<InputNumber min={0} max={100} addonAfter="%" style={{ width: 140 }} /> |
||||||
|
</Form.Item> |
||||||
|
|
||||||
|
<Form.Item label="Ограничение по времени"> |
||||||
|
<Space align="center"> |
||||||
|
<Form.Item name="has_timer" valuePropName="checked" noStyle> |
||||||
|
<Switch /> |
||||||
|
</Form.Item> |
||||||
|
<Form.Item |
||||||
|
noStyle |
||||||
|
shouldUpdate={(prev, cur) => prev.has_timer !== cur.has_timer} |
||||||
|
> |
||||||
|
{({ getFieldValue }) => |
||||||
|
getFieldValue('has_timer') ? ( |
||||||
|
<Form.Item |
||||||
|
name="time_limit" |
||||||
|
noStyle |
||||||
|
rules={[{ required: true, message: 'Укажите время' }]} |
||||||
|
> |
||||||
|
<InputNumber min={1} addonAfter="мин" style={{ width: 150 }} /> |
||||||
|
</Form.Item> |
||||||
|
) : ( |
||||||
|
<span style={{ color: '#999' }}>без ограничения</span> |
||||||
|
) |
||||||
|
} |
||||||
|
</Form.Item> |
||||||
|
</Space> |
||||||
|
</Form.Item> |
||||||
|
|
||||||
|
<Form.Item |
||||||
|
name="allow_navigation_back" |
||||||
|
label="Разрешить возврат к предыдущему вопросу" |
||||||
|
valuePropName="checked" |
||||||
|
> |
||||||
|
<Switch /> |
||||||
|
</Form.Item> |
||||||
|
</Card> |
||||||
|
|
||||||
|
{/* ── Вопросы ── */} |
||||||
|
<Form.List |
||||||
|
name="questions" |
||||||
|
rules={[ |
||||||
|
{ |
||||||
|
validator: async (_, questions) => { |
||||||
|
if (!questions || questions.length < 7) { |
||||||
|
return Promise.reject(new Error('Минимум 7 вопросов')) |
||||||
|
} |
||||||
|
}, |
||||||
|
}, |
||||||
|
]} |
||||||
|
> |
||||||
|
{(questionFields, { add: addQuestion, remove: removeQuestion }, { errors }) => ( |
||||||
|
<> |
||||||
|
{questionFields.map(({ key, name: qName }, index) => ( |
||||||
|
<Card |
||||||
|
key={key} |
||||||
|
title={`Вопрос ${index + 1}`} |
||||||
|
extra={ |
||||||
|
questionFields.length > 7 ? ( |
||||||
|
<MinusCircleOutlined |
||||||
|
style={{ color: '#ff4d4f', fontSize: 16 }} |
||||||
|
onClick={() => removeQuestion(qName)} |
||||||
|
/> |
||||||
|
) : null |
||||||
|
} |
||||||
|
style={{ marginBottom: 16 }} |
||||||
|
> |
||||||
|
<Form.Item |
||||||
|
name={[qName, 'text']} |
||||||
|
rules={[{ required: true, message: 'Введите текст вопроса' }]} |
||||||
|
> |
||||||
|
<Input.TextArea rows={2} placeholder="Текст вопроса" /> |
||||||
|
</Form.Item> |
||||||
|
|
||||||
|
<Form.List |
||||||
|
name={[qName, 'answers']} |
||||||
|
rules={[ |
||||||
|
{ |
||||||
|
validator: async (_, answers) => { |
||||||
|
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 }) => ( |
||||||
|
<Space |
||||||
|
key={ak} |
||||||
|
style={{ display: 'flex', marginBottom: 8 }} |
||||||
|
align="start" |
||||||
|
> |
||||||
|
<Form.Item |
||||||
|
name={[aName, 'is_correct']} |
||||||
|
valuePropName="checked" |
||||||
|
initialValue={false} |
||||||
|
style={{ marginBottom: 0 }} |
||||||
|
> |
||||||
|
<Checkbox /> |
||||||
|
</Form.Item> |
||||||
|
|
||||||
|
<Form.Item |
||||||
|
name={[aName, 'text']} |
||||||
|
rules={[{ required: true, message: 'Введите вариант ответа' }]} |
||||||
|
style={{ marginBottom: 0, flex: 1 }} |
||||||
|
> |
||||||
|
<Input placeholder="Вариант ответа" style={{ width: 440 }} /> |
||||||
|
</Form.Item> |
||||||
|
|
||||||
|
{answerFields.length > 3 && ( |
||||||
|
<MinusCircleOutlined |
||||||
|
style={{ color: '#ff4d4f' }} |
||||||
|
onClick={() => removeAnswer(aName)} |
||||||
|
/> |
||||||
|
)} |
||||||
|
</Space> |
||||||
|
))} |
||||||
|
|
||||||
|
<Form.ErrorList errors={answerErrors} /> |
||||||
|
|
||||||
|
<Button |
||||||
|
type="dashed" |
||||||
|
size="small" |
||||||
|
icon={<PlusOutlined />} |
||||||
|
onClick={() => addAnswer({ text: '', is_correct: false })} |
||||||
|
style={{ marginTop: 4 }} |
||||||
|
> |
||||||
|
Добавить вариант |
||||||
|
</Button> |
||||||
|
</> |
||||||
|
)} |
||||||
|
</Form.List> |
||||||
|
</Card> |
||||||
|
))} |
||||||
|
|
||||||
|
<Form.ErrorList errors={errors} /> |
||||||
|
|
||||||
|
<Button |
||||||
|
type="dashed" |
||||||
|
block |
||||||
|
icon={<PlusOutlined />} |
||||||
|
style={{ marginBottom: 24 }} |
||||||
|
onClick={() => |
||||||
|
addQuestion({ text: '', answers: [EMPTY_ANSWER, EMPTY_ANSWER, EMPTY_ANSWER] }) |
||||||
|
} |
||||||
|
> |
||||||
|
Добавить вопрос |
||||||
|
</Button> |
||||||
|
</> |
||||||
|
)} |
||||||
|
</Form.List> |
||||||
|
|
||||||
|
<Form.Item> |
||||||
|
<Space> |
||||||
|
<Button type="primary" htmlType="submit" loading={isPending}> |
||||||
|
{submitLabel} |
||||||
|
</Button> |
||||||
|
<Button onClick={onCancel}>Отмена</Button> |
||||||
|
</Space> |
||||||
|
</Form.Item> |
||||||
|
</Form> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
Loading…
Reference in new issue