diff --git a/backend/app/api/tests.py b/backend/app/api/tests.py index 8c6bcf6..e27d825 100644 --- a/backend/app/api/tests.py +++ b/backend/app/api/tests.py @@ -1,5 +1,5 @@ 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.orm import selectinload @@ -13,13 +13,11 @@ router = APIRouter(prefix="/api/tests", tags=["tests"]) @router.get("", response_model=list[TestListItem]) async def list_tests(db: AsyncSession = Depends(get_db)): - # Показываем только последние версии: те, чей id не упоминается как parent_id - parent_ids_subq = select(Test.parent_id).where(Test.parent_id.is_not(None)) - + # Показываем только активные версии (is_active = True) result = await db.execute( select(Test) .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()) ) tests = result.scalars().all() @@ -35,10 +33,11 @@ async def list_tests(db: AsyncSession = Depends(get_db)): @router.get("/{test_id}", response_model=TestOut) async def get_test(test_id: int, db: AsyncSession = Depends(get_db)): + # Загружаем любую версию по id (без фильтра is_active — нужно для просмотра истории) result = await db.execute( select(Test) .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() 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]) 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: @@ -118,23 +116,54 @@ async def get_test_versions(test_id: int, db: AsyncSession = Depends(get_db)): return items -@router.put("/{test_id}", response_model=TestUpdateResponse) -async def update_test(test_id: int, data: TestCreate, db: AsyncSession = Depends(get_db)): +@router.post("/{test_id}/activate", response_model=TestOut) +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( 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() if not test: raise HTTPException(status_code=404, detail="Тест не найден") - # Проверяем, есть ли уже попытки прохождения attempts_count = await db.scalar( select(func.count()).select_from(TestAttempt).where(TestAttempt.test_id == test_id) ) if attempts_count == 0: - # Редактируем на месте: обновляем поля, пересоздаём вопросы + # Редактируем на месте test.title = data.title test.description = data.description 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} else: - # Есть попытки — создаём новую версию + # Есть попытки — создаём новую версию, деактивируем текущую + test.is_active = False + new_test = Test( title=data.title, 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, version=test.version + 1, parent_id=test.id, + is_active=True, ) db.add(new_test) await db.flush() diff --git a/frontend/src/api/tests.ts b/frontend/src/api/tests.ts index 19f7e09..55a3097 100644 --- a/frontend/src/api/tests.ts +++ b/frontend/src/api/tests.ts @@ -70,4 +70,5 @@ export const testsApi = { update: (id: number, data: CreateTestDto) => client.put(`/tests/${id}`, data), versions: (id: number) => client.get(`/tests/${id}/versions`), + activate: (id: number) => client.post(`/tests/${id}/activate`), } diff --git a/frontend/src/pages/TestEdit/index.tsx b/frontend/src/pages/TestEdit/index.tsx index fb334d4..252ff53 100644 --- a/frontend/src/pages/TestEdit/index.tsx +++ b/frontend/src/pages/TestEdit/index.tsx @@ -33,6 +33,17 @@ export default function TestEdit() { 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({ mutationFn: (data: CreateTestDto) => testsApi.update(Number(id), data).then((r) => r.data), onSuccess: (result) => { @@ -173,13 +184,19 @@ export default function TestEdit() { { title: 'Версия', dataIndex: 'version', - width: 90, - render: (v: number, record: TestListItem) => ( - - v{v} - {record.id === test.id && (текущая)} - - ), + width: 70, + render: (v: number) => v{v}, + }, + { + title: 'Статус', + dataIndex: 'is_active', + width: 130, + render: (active: boolean) => + active ? ( + Активная + ) : ( + Неактивная + ), }, { title: 'Дата', @@ -203,13 +220,25 @@ export default function TestEdit() { { title: '', key: 'action', - width: 100, - render: (_: unknown, record: TestListItem) => - record.id !== test.id ? ( - - ) : null, + render: (_: unknown, record: TestListItem) => ( + + {record.id !== test.id && ( + + )} + {!record.is_active && ( + + )} + + ), }, ] as ColumnsType }