Browse Source

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>
master
Aleksey Razorvin 1 week ago
parent
commit
1103201ee3
  1. 58
      backend/app/api/tests.py
  2. 1
      frontend/src/api/tests.ts
  3. 57
      frontend/src/pages/TestEdit/index.tsx

58
backend/app/api/tests.py

@ -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).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()
# Собираем все 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( result = await db.execute(
select(Test) select(Test)
.where(Test.id == test_id, Test.is_active == True) .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()

1
frontend/src/api/tests.ts

@ -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`),
} }

57
frontend/src/pages/TestEdit/index.tsx

@ -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,13 +184,19 @@ 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>
),
}, },
{ {
title: 'Дата', title: 'Дата',
@ -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>
} }

Loading…
Cancel
Save