feat: add version history section to test edit page
- GET /api/tests/{id}/versions — returns full version chain from oldest to newest
- TestEdit: shows 'История версий' table when multiple versions exist,
current version highlighted, links to navigate between versions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -80,6 +80,44 @@ async def create_test(data: TestCreate, db: AsyncSession = Depends(get_db)):
|
|||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{test_id}/versions", response_model=list[TestListItem])
|
||||||
|
async def get_test_versions(test_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Возвращает все версии теста (от первой к последней)."""
|
||||||
|
# Загружаем текущий тест
|
||||||
|
result = await db.execute(select(Test).where(Test.id == test_id))
|
||||||
|
test = result.scalar_one_or_none()
|
||||||
|
if not test:
|
||||||
|
raise HTTPException(status_code=404, detail="Тест не найден")
|
||||||
|
|
||||||
|
# Идём вверх до корневой версии
|
||||||
|
root = test
|
||||||
|
while root.parent_id is not None:
|
||||||
|
result = await db.execute(select(Test).where(Test.id == root.parent_id))
|
||||||
|
root = result.scalar_one()
|
||||||
|
|
||||||
|
# Идём вниз от корня, собирая цепочку
|
||||||
|
versions: list[Test] = []
|
||||||
|
current = root
|
||||||
|
while current is not None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Test)
|
||||||
|
.options(selectinload(Test.questions))
|
||||||
|
.where(Test.id == current.id)
|
||||||
|
)
|
||||||
|
current_with_qs = result.scalar_one()
|
||||||
|
versions.append(current_with_qs)
|
||||||
|
|
||||||
|
result = await db.execute(select(Test).where(Test.parent_id == current.id))
|
||||||
|
current = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for v in versions:
|
||||||
|
item = TestListItem.model_validate(v)
|
||||||
|
item.questions_count = len(v.questions)
|
||||||
|
items.append(item)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{test_id}", response_model=TestUpdateResponse)
|
@router.put("/{test_id}", response_model=TestUpdateResponse)
|
||||||
async def update_test(test_id: int, data: TestCreate, db: AsyncSession = Depends(get_db)):
|
async def update_test(test_id: int, data: TestCreate, db: AsyncSession = Depends(get_db)):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
|
|||||||
@@ -69,4 +69,5 @@ export const testsApi = {
|
|||||||
create: (data: CreateTestDto) => client.post<Test>('/tests', data),
|
create: (data: CreateTestDto) => client.post<Test>('/tests', data),
|
||||||
update: (id: number, data: CreateTestDto) =>
|
update: (id: number, data: CreateTestDto) =>
|
||||||
client.put<UpdateTestResponse>(`/tests/${id}`, data),
|
client.put<UpdateTestResponse>(`/tests/${id}`, data),
|
||||||
|
versions: (id: number) => client.get<TestListItem[]>(`/tests/${id}/versions`),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import {
|
|||||||
PlayCircleOutlined,
|
PlayCircleOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Alert, Button, Card, Descriptions, List, Space, Spin, Tag, Typography, message } from 'antd'
|
import { Alert, Button, Card, Descriptions, List, Space, Spin, Table, Tag, Typography, message } from 'antd'
|
||||||
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
|
||||||
import { Answer, CreateTestDto, testsApi } from '../../api/tests'
|
import { Answer, CreateTestDto, TestListItem, testsApi } from '../../api/tests'
|
||||||
import TestForm, { TestFormValues } from '../../components/TestForm'
|
import TestForm, { TestFormValues } from '../../components/TestForm'
|
||||||
|
|
||||||
const { Title, Text } = Typography
|
const { Title, Text } = Typography
|
||||||
@@ -26,6 +27,12 @@ export default function TestEdit() {
|
|||||||
queryFn: () => testsApi.get(Number(id)).then((r) => r.data),
|
queryFn: () => testsApi.get(Number(id)).then((r) => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: versions = [] } = useQuery({
|
||||||
|
queryKey: ['tests', id, 'versions'],
|
||||||
|
queryFn: () => testsApi.versions(Number(id)).then((r) => r.data),
|
||||||
|
enabled: !editMode,
|
||||||
|
})
|
||||||
|
|
||||||
const { mutate: updateTest, isPending } = useMutation({
|
const { mutate: updateTest, isPending } = useMutation({
|
||||||
mutationFn: (data: CreateTestDto) => testsApi.update(Number(id), data).then((r) => r.data),
|
mutationFn: (data: CreateTestDto) => testsApi.update(Number(id), data).then((r) => r.data),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
@@ -174,6 +181,66 @@ export default function TestEdit() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{versions.length > 1 && (
|
||||||
|
<>
|
||||||
|
<Title level={3} style={{ marginTop: 32 }}>
|
||||||
|
История версий
|
||||||
|
</Title>
|
||||||
|
<Table<TestListItem>
|
||||||
|
dataSource={versions}
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
rowClassName={(record) => (record.id === test.id ? 'ant-table-row-selected' : '')}
|
||||||
|
columns={
|
||||||
|
[
|
||||||
|
{
|
||||||
|
title: 'Версия',
|
||||||
|
dataIndex: 'version',
|
||||||
|
width: 90,
|
||||||
|
render: (v: number, record: TestListItem) => (
|
||||||
|
<Space>
|
||||||
|
<Tag color={record.id === test.id ? 'blue' : 'default'}>v{v}</Tag>
|
||||||
|
{record.id === test.id && <Text type="secondary">(текущая)</Text>}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Дата',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
width: 120,
|
||||||
|
render: (d: string) => new Date(d).toLocaleDateString('ru-RU'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Вопросов',
|
||||||
|
dataIndex: 'questions_count',
|
||||||
|
width: 100,
|
||||||
|
align: 'center' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Порог зачёта',
|
||||||
|
dataIndex: 'passing_score',
|
||||||
|
width: 120,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (s: number) => `${s}%`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'action',
|
||||||
|
width: 100,
|
||||||
|
render: (_: unknown, record: TestListItem) =>
|
||||||
|
record.id !== test.id ? (
|
||||||
|
<Button size="small" onClick={() => navigate(`/tests/${record.id}/edit`)}>
|
||||||
|
Открыть
|
||||||
|
</Button>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
] as ColumnsType<TestListItem>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user