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