feat: add version activation — choose which version is active
Backend:
- POST /api/tests/{id}/activate — deactivates all versions in chain, activates selected
- GET /api/tests — simplified to is_active=True only (no parent_id subquery)
- GET/PUT /api/tests/{id} — removed is_active filter, any version accessible by id
- PUT /api/tests/{id} — new version auto-activates, parent deactivates
Frontend:
- Version history table: status column (Активная/Неактивная), 'Сделать активной' button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+48
-16
@@ -1,5 +1,5 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
@@ -13,13 +13,11 @@ router = APIRouter(prefix="/api/tests", tags=["tests"])
|
|||||||
|
|
||||||
@router.get("", response_model=list[TestListItem])
|
@router.get("", response_model=list[TestListItem])
|
||||||
async def list_tests(db: AsyncSession = Depends(get_db)):
|
async def list_tests(db: AsyncSession = Depends(get_db)):
|
||||||
# Показываем только последние версии: те, чей id не упоминается как parent_id
|
# Показываем только активные версии (is_active = True)
|
||||||
parent_ids_subq = select(Test.parent_id).where(Test.parent_id.is_not(None))
|
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Test)
|
select(Test)
|
||||||
.options(selectinload(Test.questions))
|
.options(selectinload(Test.questions))
|
||||||
.where(Test.is_active == True, Test.id.not_in(parent_ids_subq))
|
.where(Test.is_active == True)
|
||||||
.order_by(Test.created_at.desc())
|
.order_by(Test.created_at.desc())
|
||||||
)
|
)
|
||||||
tests = result.scalars().all()
|
tests = result.scalars().all()
|
||||||
@@ -35,10 +33,11 @@ async def list_tests(db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
@router.get("/{test_id}", response_model=TestOut)
|
@router.get("/{test_id}", response_model=TestOut)
|
||||||
async def get_test(test_id: int, db: AsyncSession = Depends(get_db)):
|
async def get_test(test_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
# Загружаем любую версию по id (без фильтра is_active — нужно для просмотра истории)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Test)
|
select(Test)
|
||||||
.options(selectinload(Test.questions).selectinload(Question.answers))
|
.options(selectinload(Test.questions).selectinload(Question.answers))
|
||||||
.where(Test.id == test_id, Test.is_active == True)
|
.where(Test.id == test_id)
|
||||||
)
|
)
|
||||||
test = result.scalar_one_or_none()
|
test = result.scalar_one_or_none()
|
||||||
if not test:
|
if not test:
|
||||||
@@ -83,7 +82,6 @@ async def create_test(data: TestCreate, db: AsyncSession = Depends(get_db)):
|
|||||||
@router.get("/{test_id}/versions", response_model=list[TestListItem])
|
@router.get("/{test_id}/versions", response_model=list[TestListItem])
|
||||||
async def get_test_versions(test_id: int, db: AsyncSession = Depends(get_db)):
|
async def get_test_versions(test_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
"""Возвращает все версии теста (от первой к последней)."""
|
"""Возвращает все версии теста (от первой к последней)."""
|
||||||
# Загружаем текущий тест
|
|
||||||
result = await db.execute(select(Test).where(Test.id == test_id))
|
result = await db.execute(select(Test).where(Test.id == test_id))
|
||||||
test = result.scalar_one_or_none()
|
test = result.scalar_one_or_none()
|
||||||
if not test:
|
if not test:
|
||||||
@@ -118,23 +116,54 @@ async def get_test_versions(test_id: int, db: AsyncSession = Depends(get_db)):
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{test_id}", response_model=TestUpdateResponse)
|
@router.post("/{test_id}/activate", response_model=TestOut)
|
||||||
async def update_test(test_id: int, data: TestCreate, db: AsyncSession = Depends(get_db)):
|
async def activate_test_version(test_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
result = await db.execute(
|
"""Делает указанную версию активной, деактивирует все остальные в цепочке."""
|
||||||
select(Test)
|
result = await db.execute(select(Test).where(Test.id == test_id))
|
||||||
.where(Test.id == test_id, Test.is_active == True)
|
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()
|
||||||
|
|
||||||
|
# Собираем все id версий в цепочке
|
||||||
|
all_ids: list[int] = []
|
||||||
|
current = root
|
||||||
|
while current is not None:
|
||||||
|
all_ids.append(current.id)
|
||||||
|
result = await db.execute(select(Test).where(Test.parent_id == current.id))
|
||||||
|
current = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# Деактивируем все, активируем нужную
|
||||||
|
await db.execute(update(Test).where(Test.id.in_(all_ids)).values(is_active=False))
|
||||||
|
await db.execute(update(Test).where(Test.id == test_id).values(is_active=True))
|
||||||
|
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 = result.scalar_one_or_none()
|
test = result.scalar_one_or_none()
|
||||||
if not test:
|
if not test:
|
||||||
raise HTTPException(status_code=404, detail="Тест не найден")
|
raise HTTPException(status_code=404, detail="Тест не найден")
|
||||||
|
|
||||||
# Проверяем, есть ли уже попытки прохождения
|
|
||||||
attempts_count = await db.scalar(
|
attempts_count = await db.scalar(
|
||||||
select(func.count()).select_from(TestAttempt).where(TestAttempt.test_id == test_id)
|
select(func.count()).select_from(TestAttempt).where(TestAttempt.test_id == test_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
if attempts_count == 0:
|
if attempts_count == 0:
|
||||||
# Редактируем на месте: обновляем поля, пересоздаём вопросы
|
# Редактируем на месте
|
||||||
test.title = data.title
|
test.title = data.title
|
||||||
test.description = data.description
|
test.description = data.description
|
||||||
test.passing_score = data.passing_score
|
test.passing_score = data.passing_score
|
||||||
@@ -165,7 +194,9 @@ async def update_test(test_id: int, data: TestCreate, db: AsyncSession = Depends
|
|||||||
return {"test": result.scalar_one(), "is_new_version": False}
|
return {"test": result.scalar_one(), "is_new_version": False}
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Есть попытки — создаём новую версию
|
# Есть попытки — создаём новую версию, деактивируем текущую
|
||||||
|
test.is_active = False
|
||||||
|
|
||||||
new_test = Test(
|
new_test = Test(
|
||||||
title=data.title,
|
title=data.title,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
@@ -174,6 +205,7 @@ async def update_test(test_id: int, data: TestCreate, db: AsyncSession = Depends
|
|||||||
allow_navigation_back=data.allow_navigation_back,
|
allow_navigation_back=data.allow_navigation_back,
|
||||||
version=test.version + 1,
|
version=test.version + 1,
|
||||||
parent_id=test.id,
|
parent_id=test.id,
|
||||||
|
is_active=True,
|
||||||
)
|
)
|
||||||
db.add(new_test)
|
db.add(new_test)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|||||||
@@ -70,4 +70,5 @@ export const testsApi = {
|
|||||||
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`),
|
versions: (id: number) => client.get<TestListItem[]>(`/tests/${id}/versions`),
|
||||||
|
activate: (id: number) => client.post<Test>(`/tests/${id}/activate`),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ export default function TestEdit() {
|
|||||||
enabled: !editMode,
|
enabled: !editMode,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { mutate: activateVersion, isPending: isActivating } = useMutation({
|
||||||
|
mutationFn: (versionId: number) => testsApi.activate(versionId).then((r) => r.data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tests'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tests', id, 'versions'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tests', id] })
|
||||||
|
message.success('Активная версия изменена')
|
||||||
|
},
|
||||||
|
onError: () => message.error('Не удалось изменить активную версию'),
|
||||||
|
})
|
||||||
|
|
||||||
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) => {
|
||||||
@@ -173,12 +184,18 @@ export default function TestEdit() {
|
|||||||
{
|
{
|
||||||
title: 'Версия',
|
title: 'Версия',
|
||||||
dataIndex: 'version',
|
dataIndex: 'version',
|
||||||
width: 90,
|
width: 70,
|
||||||
render: (v: number, record: TestListItem) => (
|
render: (v: number) => <Tag color="default">v{v}</Tag>,
|
||||||
<Space>
|
},
|
||||||
<Tag color={record.id === test.id ? 'blue' : 'default'}>v{v}</Tag>
|
{
|
||||||
{record.id === test.id && <Text type="secondary">(текущая)</Text>}
|
title: 'Статус',
|
||||||
</Space>
|
dataIndex: 'is_active',
|
||||||
|
width: 130,
|
||||||
|
render: (active: boolean) =>
|
||||||
|
active ? (
|
||||||
|
<Tag color="green">Активная</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag color="default">Неактивная</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -203,13 +220,25 @@ export default function TestEdit() {
|
|||||||
{
|
{
|
||||||
title: '',
|
title: '',
|
||||||
key: 'action',
|
key: 'action',
|
||||||
width: 100,
|
render: (_: unknown, record: TestListItem) => (
|
||||||
render: (_: unknown, record: TestListItem) =>
|
<Space>
|
||||||
record.id !== test.id ? (
|
{record.id !== test.id && (
|
||||||
<Button size="small" onClick={() => navigate(`/tests/${record.id}/edit`)}>
|
<Button size="small" onClick={() => navigate(`/tests/${record.id}/edit`)}>
|
||||||
Открыть
|
Открыть
|
||||||
</Button>
|
</Button>
|
||||||
) : null,
|
)}
|
||||||
|
{!record.is_active && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
loading={isActivating}
|
||||||
|
onClick={() => activateVersion(record.id)}
|
||||||
|
>
|
||||||
|
Сделать активной
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
] as ColumnsType<TestListItem>
|
] as ColumnsType<TestListItem>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user