mirror of
https://github.com/IRBorisov/ConceptPortal.git
synced 2025-06-26 04:50:36 +03:00
Implement restoreVersion
This commit is contained in:
parent
b8dd8376ea
commit
9ff6a92c4f
|
@ -16,7 +16,7 @@ from .data_access import (
|
|||
RSFormParseSerializer,
|
||||
VersionSerializer,
|
||||
VersionCreateSerializer,
|
||||
ConstituentaSerializer,
|
||||
CstSerializer,
|
||||
CstTargetSerializer,
|
||||
CstMoveSerializer,
|
||||
CstSubstituteSerializer,
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
from typing import Optional, cast
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import transaction
|
||||
from rest_framework import serializers
|
||||
from rest_framework.serializers import PrimaryKeyRelatedField as PKField
|
||||
|
||||
|
@ -19,7 +20,7 @@ class LibraryItemSerializer(serializers.ModelSerializer):
|
|||
''' serializer metadata. '''
|
||||
model = LibraryItem
|
||||
fields = '__all__'
|
||||
read_only_fields = ('owner', 'id', 'item_type')
|
||||
read_only_fields = ('id', 'item_type')
|
||||
|
||||
|
||||
class VersionSerializer(serializers.ModelSerializer):
|
||||
|
@ -66,7 +67,16 @@ class LibraryItemDetailsSerializer(serializers.ModelSerializer):
|
|||
return [VersionInnerSerializer(item).data for item in instance.versions()]
|
||||
|
||||
|
||||
class ConstituentaSerializer(serializers.ModelSerializer):
|
||||
class CstBaseSerializer(serializers.ModelSerializer):
|
||||
''' Serializer: Constituenta all data. '''
|
||||
class Meta:
|
||||
''' serializer metadata. '''
|
||||
model = Constituenta
|
||||
fields = '__all__'
|
||||
read_only_fields = ('id',)
|
||||
|
||||
|
||||
class CstSerializer(serializers.ModelSerializer):
|
||||
''' Serializer: Constituenta data. '''
|
||||
class Meta:
|
||||
''' serializer metadata. '''
|
||||
|
@ -124,7 +134,7 @@ class RSFormSerializer(serializers.ModelSerializer):
|
|||
child=serializers.IntegerField()
|
||||
)
|
||||
items = serializers.ListField(
|
||||
child=ConstituentaSerializer()
|
||||
child=CstSerializer()
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
@ -137,7 +147,7 @@ class RSFormSerializer(serializers.ModelSerializer):
|
|||
schema = RSForm(instance)
|
||||
result['items'] = []
|
||||
for cst in schema.constituents().order_by('order'):
|
||||
result['items'].append(ConstituentaSerializer(cst).data)
|
||||
result['items'].append(CstSerializer(cst).data)
|
||||
return result
|
||||
|
||||
def to_versioned_data(self) -> dict:
|
||||
|
@ -159,6 +169,45 @@ class RSFormSerializer(serializers.ModelSerializer):
|
|||
result['version'] = version
|
||||
return result | data
|
||||
|
||||
@transaction.atomic
|
||||
def restore_from_version(self, data: dict):
|
||||
''' Load data from version. '''
|
||||
schema = RSForm(cast(LibraryItem, self.instance))
|
||||
items: list[dict] = data['items']
|
||||
ids: list[int] = [item['id'] for item in items]
|
||||
processed: list[int] = []
|
||||
|
||||
for cst in schema.constituents():
|
||||
if not cst.pk in ids:
|
||||
cst.delete()
|
||||
else:
|
||||
cst_data = next(x for x in items if x['id'] == cst.pk)
|
||||
new_cst = CstBaseSerializer(data=cst_data)
|
||||
new_cst.is_valid(raise_exception=True)
|
||||
new_cst.update(
|
||||
instance=cst,
|
||||
validated_data=new_cst.validated_data
|
||||
)
|
||||
processed.append(cst.pk)
|
||||
|
||||
for cst_data in items:
|
||||
if cst_data['id'] not in processed:
|
||||
cst = schema.insert_new(cst_data['alias'])
|
||||
cst_data['id'] = cst.pk
|
||||
new_cst = CstBaseSerializer(data=cst_data)
|
||||
new_cst.is_valid(raise_exception=True)
|
||||
new_cst.update(
|
||||
instance=cst,
|
||||
validated_data=new_cst.validated_data
|
||||
)
|
||||
|
||||
loaded_item = LibraryItemSerializer(data=data)
|
||||
loaded_item.is_valid(raise_exception=True)
|
||||
loaded_item.update(
|
||||
instance=cast(LibraryItem, self.instance),
|
||||
validated_data=loaded_item.validated_data
|
||||
)
|
||||
|
||||
|
||||
class RSFormParseSerializer(serializers.ModelSerializer):
|
||||
''' Serializer: Detailed data for RSForm including parse. '''
|
||||
|
|
|
@ -15,13 +15,11 @@ class TestVersionViews(EndpointTester):
|
|||
def setUp(self):
|
||||
super().setUp()
|
||||
self.owned = RSForm.create(title='Test', alias='T1', owner=self.user).item
|
||||
self.schema = RSForm(self.owned)
|
||||
self.unowned = RSForm.create(title='Test2', alias='T2').item
|
||||
self.x1 = Constituenta.objects.create(
|
||||
schema=self.owned,
|
||||
self.x1 = self.schema.insert_new(
|
||||
alias='X1',
|
||||
cst_type='basic',
|
||||
convention='testStart',
|
||||
order=1
|
||||
convention='testStart'
|
||||
)
|
||||
|
||||
|
||||
|
@ -138,6 +136,39 @@ class TestVersionViews(EndpointTester):
|
|||
self.assertIn('document.json', zipped_file.namelist())
|
||||
|
||||
|
||||
@decl_endpoint('/api/versions/{version}/restore', method='patch')
|
||||
def test_restore_version(self):
|
||||
x1 = self.x1
|
||||
x2 = self.schema.insert_new('X2')
|
||||
d1 = self.schema.insert_new('D1', term_raw='TestTerm')
|
||||
data = {'version': '1.0.0', 'description': 'test'}
|
||||
version_id = self._create_version(data)
|
||||
invalid_id = version_id + 1337
|
||||
|
||||
d1.delete()
|
||||
x3 = self.schema.insert_new('X3')
|
||||
x1.order = x3.order
|
||||
x1.convention = 'Test2'
|
||||
x1.term_raw = 'Test'
|
||||
x1.save()
|
||||
x3.order = 1
|
||||
x3.save()
|
||||
|
||||
self.assertNotFound(version=invalid_id)
|
||||
|
||||
response = self.execute(version=version_id)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
x1.refresh_from_db()
|
||||
x2.refresh_from_db()
|
||||
self.assertEqual(len(response.data['items']), 3)
|
||||
self.assertEqual(x1.order, 1)
|
||||
self.assertEqual(x1.convention, 'testStart')
|
||||
self.assertEqual(x1.term_raw, '')
|
||||
self.assertEqual(x2.order, 2)
|
||||
self.assertEqual(response.data['items'][2]['alias'], 'D1')
|
||||
self.assertEqual(response.data['items'][2]['term_raw'], 'TestTerm')
|
||||
|
||||
|
||||
def _create_version(self, data) -> int:
|
||||
response = self.client.post(
|
||||
f'/api/rsforms/{self.owned.id}/versions/create',
|
||||
|
|
|
@ -6,6 +6,7 @@ from . import views
|
|||
library_router = routers.SimpleRouter(trailing_slash=False)
|
||||
library_router.register('library', views.LibraryViewSet, 'Library')
|
||||
library_router.register('rsforms', views.RSFormViewSet, 'RSForm')
|
||||
library_router.register('versions', views.VersionViewset, 'Version')
|
||||
|
||||
urlpatterns = [
|
||||
path('library/active', views.LibraryActiveView.as_view()),
|
||||
|
@ -15,7 +16,6 @@ urlpatterns = [
|
|||
path('rsforms/import-trs', views.TrsImportView.as_view()),
|
||||
path('rsforms/create-detailed', views.create_rsform),
|
||||
|
||||
path('versions/<int:pk>', views.VersionAPIView.as_view()),
|
||||
path('versions/<int:pk>/export-file', views.export_file),
|
||||
path('rsforms/<int:pk_item>/versions/create', views.create_version),
|
||||
path('rsforms/<int:pk_item>/versions/<int:pk_version>', views.retrieve_version),
|
||||
|
|
|
@ -7,7 +7,7 @@ from .library import (
|
|||
)
|
||||
from .constituents import ConstituentAPIView
|
||||
from .versions import (
|
||||
VersionAPIView,
|
||||
VersionViewset,
|
||||
create_version,
|
||||
export_file,
|
||||
retrieve_version
|
||||
|
|
|
@ -12,7 +12,7 @@ from .. import utils
|
|||
class ConstituentAPIView(generics.RetrieveUpdateAPIView):
|
||||
''' Endpoint: Get / Update Constituenta. '''
|
||||
queryset = m.Constituenta.objects.all()
|
||||
serializer_class = s.ConstituentaSerializer
|
||||
serializer_class = s.CstSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
result = super().get_permissions()
|
||||
|
|
|
@ -62,7 +62,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
|
|||
response = Response(
|
||||
status=c.HTTP_201_CREATED,
|
||||
data={
|
||||
'new_cst': s.ConstituentaSerializer(new_cst).data,
|
||||
'new_cst': s.CstSerializer(new_cst).data,
|
||||
'schema': s.RSFormParseSerializer(schema.item).data
|
||||
}
|
||||
)
|
||||
|
@ -138,7 +138,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
|
|||
return Response(
|
||||
status=c.HTTP_200_OK,
|
||||
data={
|
||||
'new_cst': s.ConstituentaSerializer(cst).data,
|
||||
'new_cst': s.CstSerializer(cst).data,
|
||||
'schema': s.RSFormParseSerializer(schema.item).data
|
||||
}
|
||||
)
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
''' Endpoints for versions. '''
|
||||
from typing import cast
|
||||
from django.http import HttpResponse
|
||||
from rest_framework import generics, permissions
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework import generics, permissions, viewsets
|
||||
from rest_framework.decorators import action, api_view, permission_classes
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.request import Request
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
|
@ -14,7 +15,7 @@ from .. import utils
|
|||
|
||||
@extend_schema(tags=['Version'])
|
||||
@extend_schema_view()
|
||||
class VersionAPIView(generics.RetrieveUpdateDestroyAPIView):
|
||||
class VersionViewset(viewsets.GenericViewSet, generics.RetrieveUpdateDestroyAPIView):
|
||||
''' Endpoint: Get / Update Constituenta. '''
|
||||
queryset = m.Version.objects.all()
|
||||
serializer_class = s.VersionSerializer
|
||||
|
@ -27,6 +28,26 @@ class VersionAPIView(generics.RetrieveUpdateDestroyAPIView):
|
|||
result.append(utils.ItemOwnerOrAdmin())
|
||||
return result
|
||||
|
||||
@extend_schema(
|
||||
summary='restore version data into current item',
|
||||
request=None,
|
||||
responses={
|
||||
c.HTTP_200_OK: s.RSFormParseSerializer,
|
||||
c.HTTP_403_FORBIDDEN: None,
|
||||
c.HTTP_404_NOT_FOUND: None
|
||||
}
|
||||
)
|
||||
@action(detail=True, methods=['patch'], url_path='restore')
|
||||
def restore(self, request: Request, pk):
|
||||
''' Restore version data into current item. '''
|
||||
version = cast(m.Version, self.get_object())
|
||||
item = cast(m.LibraryItem, version.item)
|
||||
s.RSFormSerializer(item).restore_from_version(version.data)
|
||||
return Response(
|
||||
status=c.HTTP_200_OK,
|
||||
data=s.RSFormParseSerializer(item).data
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
summary='save version for RSForm copying current content',
|
||||
|
|
|
@ -453,6 +453,14 @@ export function patchVersion(target: string, request: FrontPush<IVersionData>) {
|
|||
});
|
||||
}
|
||||
|
||||
export function patchRestoreVersion(target: string, request: FrontPull<IRSFormData>) {
|
||||
AxiosPatch({
|
||||
title: `Restore version id=${target}`,
|
||||
endpoint: `/api/versions/${target}/restore`,
|
||||
request: request
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteVersion(target: string, request: FrontAction) {
|
||||
AxiosDelete({
|
||||
title: `Version id=${target}`,
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
patchRenameConstituenta,
|
||||
patchResetAliases,
|
||||
patchRestoreOrder,
|
||||
patchRestoreVersion,
|
||||
patchSubstituteConstituents,
|
||||
patchUploadTRS,
|
||||
patchVersion,
|
||||
|
@ -50,6 +51,7 @@ import { useLibrary } from './LibraryContext';
|
|||
interface IRSFormContext {
|
||||
schema?: IRSForm;
|
||||
schemaID: string;
|
||||
versionID?: string;
|
||||
|
||||
error: ErrorData;
|
||||
loading: boolean;
|
||||
|
@ -82,6 +84,7 @@ interface IRSFormContext {
|
|||
versionCreate: (data: IVersionData, callback?: (version: number) => void) => void;
|
||||
versionUpdate: (target: number, data: IVersionData, callback?: () => void) => void;
|
||||
versionDelete: (target: number, callback?: () => void) => void;
|
||||
versionRestore: (target: string, callback?: () => void) => void;
|
||||
}
|
||||
|
||||
const RSFormContext = createContext<IRSFormContext | null>(null);
|
||||
|
@ -490,6 +493,22 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
|
|||
[setError, schema, setSchema]
|
||||
);
|
||||
|
||||
const versionRestore = useCallback(
|
||||
(target: string, callback?: () => void) => {
|
||||
setError(undefined);
|
||||
patchRestoreVersion(target, {
|
||||
showError: true,
|
||||
setLoading: setProcessing,
|
||||
onError: setError,
|
||||
onSuccess: () => {
|
||||
setSchema(schema);
|
||||
if (callback) callback();
|
||||
}
|
||||
});
|
||||
},
|
||||
[setError, schema, setSchema]
|
||||
);
|
||||
|
||||
const inlineSynthesis = useCallback(
|
||||
(data: IInlineSynthesisData, callback?: DataCallback<IRSFormData>) => {
|
||||
setError(undefined);
|
||||
|
@ -513,6 +532,7 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
|
|||
value={{
|
||||
schema,
|
||||
schemaID,
|
||||
versionID,
|
||||
error,
|
||||
loading,
|
||||
processing,
|
||||
|
@ -538,7 +558,8 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
|
|||
cstMoveTo,
|
||||
versionCreate,
|
||||
versionUpdate,
|
||||
versionDelete
|
||||
versionDelete,
|
||||
versionRestore
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
|
@ -6,9 +6,12 @@ import {
|
|||
IconDownload,
|
||||
IconFollow,
|
||||
IconImmutable,
|
||||
IconList,
|
||||
IconNewItem,
|
||||
IconOwner,
|
||||
IconPublic,
|
||||
IconSave
|
||||
IconSave,
|
||||
IconUpload
|
||||
} from '../../../components/Icons';
|
||||
import LinkTopic from '../../../components/ui/LinkTopic';
|
||||
|
||||
|
@ -26,10 +29,15 @@ function HelpRSFormCard() {
|
|||
<li><IconOwner className='inline-icon'/> Владелец обладает правом редактирования</li>
|
||||
<li><IconPublic className='inline-icon'/> Общедоступные схемы доступны для всех</li>
|
||||
<li><IconImmutable className='inline-icon'/> Неизменные схемы редактируют только администраторы</li>
|
||||
<li><IconClone className='inline-icon'/> Клонировать – создать копию схемы</li>
|
||||
<li><IconClone className='inline-icon icon-green'/> Клонировать – создать копию схемы</li>
|
||||
<li><IconFollow className='inline-icon'/> Отслеживание – схема в персональном списке</li>
|
||||
<li><IconDownload className='inline-icon'/> Загрузить/Выгрузить – взаимодействие с Экстеор</li>
|
||||
<li><IconDestroy className='inline-icon icon-red'/> Удалить – полностью удаляет схему из базы Портала</li>
|
||||
|
||||
<h2>Версионирование</h2>
|
||||
<li><IconNewItem className='inline-icon icon-green'/> Создать версию можно только из актуальной схемы</li>
|
||||
<li><IconUpload className='inline-icon icon-red'/> Загрузить версию в актуальную схему</li>
|
||||
<li><IconList className='inline-icon'/> Редактировать атрибуты версий</li>
|
||||
</div>);
|
||||
}
|
||||
|
||||
|
|
|
@ -50,7 +50,7 @@ function HelpRSFormMenu() {
|
|||
<IconShare className='inline-icon' /> Поделиться – скопировать ссылку на схему
|
||||
</li>
|
||||
<li>
|
||||
<IconClone className='inline-icon' /> Клонировать – создать копию схемы
|
||||
<IconClone className='inline-icon icon-green' /> Клонировать – создать копию схемы
|
||||
</li>
|
||||
<li>
|
||||
<IconDownload className='inline-icon' /> Выгрузить – сохранить в файле формата Экстеор
|
||||
|
@ -87,7 +87,7 @@ function HelpRSFormMenu() {
|
|||
</div>
|
||||
|
||||
<p>
|
||||
<IconEdit2 size='1.25rem' className='inline-icon' /> операции над концептуальной схемой описаны в{' '}
|
||||
<IconEdit2 size='1.25rem' className='inline-icon icon-green' /> операции над концептуальной схемой описаны в{' '}
|
||||
<LinkTopic text='разделе Экспликация' topic={HelpTopic.RSL_OPERATIONS} />.
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
import LinkTopic from '@/components/ui/LinkTopic';
|
||||
import { HelpTopic } from '@/models/miscellaneous';
|
||||
|
||||
function HelpVersions() {
|
||||
return (
|
||||
<div className='text-justify'>
|
||||
|
@ -6,9 +9,11 @@ function HelpVersions() {
|
|||
Версионирование позволяет сохранить текущее состояние схемы под определенным именем (версией) и использовать
|
||||
ссылку на него для совместной работы. После создания версии ее содержание изменить нельзя
|
||||
</p>
|
||||
<p>Владелец обладает правом редактирования названий и создания новых версий</p>
|
||||
<p>Управление версиями происходит в Карточке схемы</p>
|
||||
<p>Функция Поделиться включает версию в ссылку</p>
|
||||
<li>Владелец обладает правом редактирования названий и создания новых версий</li>
|
||||
<li>
|
||||
Управление версиями происходит в <LinkTopic text='Карточке схемы' topic={HelpTopic.UI_RS_CARD} />
|
||||
</li>
|
||||
<li>Функция Поделиться включает версию в ссылку</li>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import clsx from 'clsx';
|
|||
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import { IconList, IconNewItem, IconSave } from '@/components/Icons';
|
||||
import { IconList, IconNewItem, IconSave, IconUpload } from '@/components/Icons';
|
||||
import BadgeHelp from '@/components/info/BadgeHelp';
|
||||
import SelectVersion from '@/components/select/SelectVersion';
|
||||
import Checkbox from '@/components/ui/Checkbox';
|
||||
|
@ -119,6 +119,12 @@ function FormRSForm({ id, isModified, setIsModified }: FormRSFormProps) {
|
|||
<Overlay position='top-[-0.25rem] right-[-0.25rem] cc-icons'>
|
||||
{controller.isMutable ? (
|
||||
<>
|
||||
<MiniButton
|
||||
title={!controller.isContentEditable ? 'Откатить к версии' : 'Переключитесь на неактуальную версию'}
|
||||
disabled={controller.isContentEditable}
|
||||
onClick={() => controller.restoreVersion()}
|
||||
icon={<IconUpload size='1.25rem' className='icon-red' />}
|
||||
/>
|
||||
<MiniButton
|
||||
title={controller.isContentEditable ? 'Создать версию' : 'Переключитесь на актуальную версию'}
|
||||
disabled={!controller.isContentEditable}
|
||||
|
|
|
@ -66,6 +66,7 @@ interface IRSEditContext {
|
|||
|
||||
viewVersion: (version?: number, newTab?: boolean) => void;
|
||||
createVersion: () => void;
|
||||
restoreVersion: () => void;
|
||||
editVersions: () => void;
|
||||
|
||||
moveUp: () => void;
|
||||
|
@ -171,6 +172,26 @@ export const RSEditState = ({
|
|||
[router, model]
|
||||
);
|
||||
|
||||
const createVersion = useCallback(() => {
|
||||
if (isModified && !promptUnsaved()) {
|
||||
return;
|
||||
}
|
||||
setShowCreateVersion(true);
|
||||
}, [isModified]);
|
||||
|
||||
const restoreVersion = useCallback(() => {
|
||||
if (
|
||||
!model.versionID ||
|
||||
!window.confirm('При восстановлении архивной версии актуальная схему будет заменена. Продолжить?')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
model.versionRestore(model.versionID, () => {
|
||||
toast.success('Загрузка версии завершена');
|
||||
viewVersion(undefined);
|
||||
});
|
||||
}, [model, viewVersion]);
|
||||
|
||||
const handleCreateCst = useCallback(
|
||||
(data: ICstCreateData) => {
|
||||
if (!model.schema) {
|
||||
|
@ -254,9 +275,14 @@ export const RSEditState = ({
|
|||
if (!model.schema) {
|
||||
return;
|
||||
}
|
||||
model.versionDelete(versionID, () => toast.success('Версия удалена'));
|
||||
model.versionDelete(versionID, () => {
|
||||
toast.success('Версия удалена');
|
||||
if (String(versionID) === model.versionID) {
|
||||
viewVersion(undefined);
|
||||
}
|
||||
});
|
||||
},
|
||||
[model]
|
||||
[model, viewVersion]
|
||||
);
|
||||
|
||||
const handleUpdateVersion = useCallback(
|
||||
|
@ -505,7 +531,8 @@ export const RSEditState = ({
|
|||
deselectAll: () => setSelected([]),
|
||||
|
||||
viewVersion,
|
||||
createVersion: () => setShowCreateVersion(true),
|
||||
createVersion,
|
||||
restoreVersion,
|
||||
editVersions: () => setShowEditVersions(true),
|
||||
|
||||
moveUp,
|
||||
|
|
Loading…
Reference in New Issue
Block a user