Implement restoreVersion

This commit is contained in:
IRBorisov 2024-05-18 19:22:26 +03:00
parent b8dd8376ea
commit 9ff6a92c4f
15 changed files with 206 additions and 30 deletions

View File

@ -16,7 +16,7 @@ from .data_access import (
RSFormParseSerializer, RSFormParseSerializer,
VersionSerializer, VersionSerializer,
VersionCreateSerializer, VersionCreateSerializer,
ConstituentaSerializer, CstSerializer,
CstTargetSerializer, CstTargetSerializer,
CstMoveSerializer, CstMoveSerializer,
CstSubstituteSerializer, CstSubstituteSerializer,

View File

@ -2,6 +2,7 @@
from typing import Optional, cast from typing import Optional, cast
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import transaction
from rest_framework import serializers from rest_framework import serializers
from rest_framework.serializers import PrimaryKeyRelatedField as PKField from rest_framework.serializers import PrimaryKeyRelatedField as PKField
@ -19,7 +20,7 @@ class LibraryItemSerializer(serializers.ModelSerializer):
''' serializer metadata. ''' ''' serializer metadata. '''
model = LibraryItem model = LibraryItem
fields = '__all__' fields = '__all__'
read_only_fields = ('owner', 'id', 'item_type') read_only_fields = ('id', 'item_type')
class VersionSerializer(serializers.ModelSerializer): class VersionSerializer(serializers.ModelSerializer):
@ -66,7 +67,16 @@ class LibraryItemDetailsSerializer(serializers.ModelSerializer):
return [VersionInnerSerializer(item).data for item in instance.versions()] 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. ''' ''' Serializer: Constituenta data. '''
class Meta: class Meta:
''' serializer metadata. ''' ''' serializer metadata. '''
@ -124,7 +134,7 @@ class RSFormSerializer(serializers.ModelSerializer):
child=serializers.IntegerField() child=serializers.IntegerField()
) )
items = serializers.ListField( items = serializers.ListField(
child=ConstituentaSerializer() child=CstSerializer()
) )
class Meta: class Meta:
@ -137,7 +147,7 @@ class RSFormSerializer(serializers.ModelSerializer):
schema = RSForm(instance) schema = RSForm(instance)
result['items'] = [] result['items'] = []
for cst in schema.constituents().order_by('order'): for cst in schema.constituents().order_by('order'):
result['items'].append(ConstituentaSerializer(cst).data) result['items'].append(CstSerializer(cst).data)
return result return result
def to_versioned_data(self) -> dict: def to_versioned_data(self) -> dict:
@ -159,6 +169,45 @@ class RSFormSerializer(serializers.ModelSerializer):
result['version'] = version result['version'] = version
return result | data 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): class RSFormParseSerializer(serializers.ModelSerializer):
''' Serializer: Detailed data for RSForm including parse. ''' ''' Serializer: Detailed data for RSForm including parse. '''

View File

@ -15,13 +15,11 @@ class TestVersionViews(EndpointTester):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.owned = RSForm.create(title='Test', alias='T1', owner=self.user).item 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.unowned = RSForm.create(title='Test2', alias='T2').item
self.x1 = Constituenta.objects.create( self.x1 = self.schema.insert_new(
schema=self.owned,
alias='X1', alias='X1',
cst_type='basic', convention='testStart'
convention='testStart',
order=1
) )
@ -138,6 +136,39 @@ class TestVersionViews(EndpointTester):
self.assertIn('document.json', zipped_file.namelist()) 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: def _create_version(self, data) -> int:
response = self.client.post( response = self.client.post(
f'/api/rsforms/{self.owned.id}/versions/create', f'/api/rsforms/{self.owned.id}/versions/create',

View File

@ -6,6 +6,7 @@ from . import views
library_router = routers.SimpleRouter(trailing_slash=False) library_router = routers.SimpleRouter(trailing_slash=False)
library_router.register('library', views.LibraryViewSet, 'Library') library_router.register('library', views.LibraryViewSet, 'Library')
library_router.register('rsforms', views.RSFormViewSet, 'RSForm') library_router.register('rsforms', views.RSFormViewSet, 'RSForm')
library_router.register('versions', views.VersionViewset, 'Version')
urlpatterns = [ urlpatterns = [
path('library/active', views.LibraryActiveView.as_view()), path('library/active', views.LibraryActiveView.as_view()),
@ -15,7 +16,6 @@ urlpatterns = [
path('rsforms/import-trs', views.TrsImportView.as_view()), path('rsforms/import-trs', views.TrsImportView.as_view()),
path('rsforms/create-detailed', views.create_rsform), 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('versions/<int:pk>/export-file', views.export_file),
path('rsforms/<int:pk_item>/versions/create', views.create_version), path('rsforms/<int:pk_item>/versions/create', views.create_version),
path('rsforms/<int:pk_item>/versions/<int:pk_version>', views.retrieve_version), path('rsforms/<int:pk_item>/versions/<int:pk_version>', views.retrieve_version),

View File

@ -7,7 +7,7 @@ from .library import (
) )
from .constituents import ConstituentAPIView from .constituents import ConstituentAPIView
from .versions import ( from .versions import (
VersionAPIView, VersionViewset,
create_version, create_version,
export_file, export_file,
retrieve_version retrieve_version

View File

@ -12,7 +12,7 @@ from .. import utils
class ConstituentAPIView(generics.RetrieveUpdateAPIView): class ConstituentAPIView(generics.RetrieveUpdateAPIView):
''' Endpoint: Get / Update Constituenta. ''' ''' Endpoint: Get / Update Constituenta. '''
queryset = m.Constituenta.objects.all() queryset = m.Constituenta.objects.all()
serializer_class = s.ConstituentaSerializer serializer_class = s.CstSerializer
def get_permissions(self): def get_permissions(self):
result = super().get_permissions() result = super().get_permissions()

View File

@ -62,7 +62,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
response = Response( response = Response(
status=c.HTTP_201_CREATED, status=c.HTTP_201_CREATED,
data={ data={
'new_cst': s.ConstituentaSerializer(new_cst).data, 'new_cst': s.CstSerializer(new_cst).data,
'schema': s.RSFormParseSerializer(schema.item).data 'schema': s.RSFormParseSerializer(schema.item).data
} }
) )
@ -138,7 +138,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
return Response( return Response(
status=c.HTTP_200_OK, status=c.HTTP_200_OK,
data={ data={
'new_cst': s.ConstituentaSerializer(cst).data, 'new_cst': s.CstSerializer(cst).data,
'schema': s.RSFormParseSerializer(schema.item).data 'schema': s.RSFormParseSerializer(schema.item).data
} }
) )

View File

@ -1,7 +1,8 @@
''' Endpoints for versions. ''' ''' Endpoints for versions. '''
from typing import cast
from django.http import HttpResponse from django.http import HttpResponse
from rest_framework import generics, permissions from rest_framework import generics, permissions, viewsets
from rest_framework.decorators import api_view, permission_classes from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.request import Request from rest_framework.request import Request
from drf_spectacular.utils import extend_schema, extend_schema_view from drf_spectacular.utils import extend_schema, extend_schema_view
@ -14,7 +15,7 @@ from .. import utils
@extend_schema(tags=['Version']) @extend_schema(tags=['Version'])
@extend_schema_view() @extend_schema_view()
class VersionAPIView(generics.RetrieveUpdateDestroyAPIView): class VersionViewset(viewsets.GenericViewSet, generics.RetrieveUpdateDestroyAPIView):
''' Endpoint: Get / Update Constituenta. ''' ''' Endpoint: Get / Update Constituenta. '''
queryset = m.Version.objects.all() queryset = m.Version.objects.all()
serializer_class = s.VersionSerializer serializer_class = s.VersionSerializer
@ -27,6 +28,26 @@ class VersionAPIView(generics.RetrieveUpdateDestroyAPIView):
result.append(utils.ItemOwnerOrAdmin()) result.append(utils.ItemOwnerOrAdmin())
return result 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( @extend_schema(
summary='save version for RSForm copying current content', summary='save version for RSForm copying current content',

View File

@ -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) { export function deleteVersion(target: string, request: FrontAction) {
AxiosDelete({ AxiosDelete({
title: `Version id=${target}`, title: `Version id=${target}`,

View File

@ -16,6 +16,7 @@ import {
patchRenameConstituenta, patchRenameConstituenta,
patchResetAliases, patchResetAliases,
patchRestoreOrder, patchRestoreOrder,
patchRestoreVersion,
patchSubstituteConstituents, patchSubstituteConstituents,
patchUploadTRS, patchUploadTRS,
patchVersion, patchVersion,
@ -50,6 +51,7 @@ import { useLibrary } from './LibraryContext';
interface IRSFormContext { interface IRSFormContext {
schema?: IRSForm; schema?: IRSForm;
schemaID: string; schemaID: string;
versionID?: string;
error: ErrorData; error: ErrorData;
loading: boolean; loading: boolean;
@ -82,6 +84,7 @@ interface IRSFormContext {
versionCreate: (data: IVersionData, callback?: (version: number) => void) => void; versionCreate: (data: IVersionData, callback?: (version: number) => void) => void;
versionUpdate: (target: number, data: IVersionData, callback?: () => void) => void; versionUpdate: (target: number, data: IVersionData, callback?: () => void) => void;
versionDelete: (target: number, callback?: () => void) => void; versionDelete: (target: number, callback?: () => void) => void;
versionRestore: (target: string, callback?: () => void) => void;
} }
const RSFormContext = createContext<IRSFormContext | null>(null); const RSFormContext = createContext<IRSFormContext | null>(null);
@ -490,6 +493,22 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
[setError, schema, setSchema] [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( const inlineSynthesis = useCallback(
(data: IInlineSynthesisData, callback?: DataCallback<IRSFormData>) => { (data: IInlineSynthesisData, callback?: DataCallback<IRSFormData>) => {
setError(undefined); setError(undefined);
@ -513,6 +532,7 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
value={{ value={{
schema, schema,
schemaID, schemaID,
versionID,
error, error,
loading, loading,
processing, processing,
@ -538,7 +558,8 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
cstMoveTo, cstMoveTo,
versionCreate, versionCreate,
versionUpdate, versionUpdate,
versionDelete versionDelete,
versionRestore
}} }}
> >
{children} {children}

View File

@ -6,9 +6,12 @@ import {
IconDownload, IconDownload,
IconFollow, IconFollow,
IconImmutable, IconImmutable,
IconList,
IconNewItem,
IconOwner, IconOwner,
IconPublic, IconPublic,
IconSave IconSave,
IconUpload
} from '../../../components/Icons'; } from '../../../components/Icons';
import LinkTopic from '../../../components/ui/LinkTopic'; import LinkTopic from '../../../components/ui/LinkTopic';
@ -26,10 +29,15 @@ function HelpRSFormCard() {
<li><IconOwner className='inline-icon'/> Владелец обладает правом редактирования</li> <li><IconOwner className='inline-icon'/> Владелец обладает правом редактирования</li>
<li><IconPublic className='inline-icon'/> Общедоступные схемы доступны для всех</li> <li><IconPublic className='inline-icon'/> Общедоступные схемы доступны для всех</li>
<li><IconImmutable 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><IconFollow className='inline-icon'/> Отслеживание схема в персональном списке</li>
<li><IconDownload className='inline-icon'/> Загрузить/Выгрузить взаимодействие с Экстеор</li> <li><IconDownload className='inline-icon'/> Загрузить/Выгрузить взаимодействие с Экстеор</li>
<li><IconDestroy className='inline-icon icon-red'/> Удалить полностью удаляет схему из базы Портала</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>); </div>);
} }

View File

@ -50,7 +50,7 @@ function HelpRSFormMenu() {
<IconShare className='inline-icon' /> Поделиться скопировать ссылку на схему <IconShare className='inline-icon' /> Поделиться скопировать ссылку на схему
</li> </li>
<li> <li>
<IconClone className='inline-icon' /> Клонировать создать копию схемы <IconClone className='inline-icon icon-green' /> Клонировать создать копию схемы
</li> </li>
<li> <li>
<IconDownload className='inline-icon' /> Выгрузить сохранить в файле формата Экстеор <IconDownload className='inline-icon' /> Выгрузить сохранить в файле формата Экстеор
@ -87,7 +87,7 @@ function HelpRSFormMenu() {
</div> </div>
<p> <p>
<IconEdit2 size='1.25rem' className='inline-icon' /> операции над концептуальной схемой описаны в{' '} <IconEdit2 size='1.25rem' className='inline-icon icon-green' /> операции над концептуальной схемой описаны в{' '}
<LinkTopic text='разделе Экспликация' topic={HelpTopic.RSL_OPERATIONS} />. <LinkTopic text='разделе Экспликация' topic={HelpTopic.RSL_OPERATIONS} />.
</p> </p>
</div> </div>

View File

@ -1,3 +1,6 @@
import LinkTopic from '@/components/ui/LinkTopic';
import { HelpTopic } from '@/models/miscellaneous';
function HelpVersions() { function HelpVersions() {
return ( return (
<div className='text-justify'> <div className='text-justify'>
@ -6,9 +9,11 @@ function HelpVersions() {
Версионирование позволяет сохранить текущее состояние схемы под определенным именем (версией) и использовать Версионирование позволяет сохранить текущее состояние схемы под определенным именем (версией) и использовать
ссылку на него для совместной работы. После создания версии ее содержание изменить нельзя ссылку на него для совместной работы. После создания версии ее содержание изменить нельзя
</p> </p>
<p>Владелец обладает правом редактирования названий и создания новых версий</p> <li>Владелец обладает правом редактирования названий и создания новых версий</li>
<p>Управление версиями происходит в Карточке схемы</p> <li>
<p>Функция Поделиться включает версию в ссылку</p> Управление версиями происходит в <LinkTopic text='Карточке схемы' topic={HelpTopic.UI_RS_CARD} />
</li>
<li>Функция Поделиться включает версию в ссылку</li>
</div> </div>
); );
} }

View File

@ -4,7 +4,7 @@ import clsx from 'clsx';
import { useEffect, useLayoutEffect, useState } from 'react'; import { useEffect, useLayoutEffect, useState } from 'react';
import { toast } from 'react-toastify'; 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 BadgeHelp from '@/components/info/BadgeHelp';
import SelectVersion from '@/components/select/SelectVersion'; import SelectVersion from '@/components/select/SelectVersion';
import Checkbox from '@/components/ui/Checkbox'; 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'> <Overlay position='top-[-0.25rem] right-[-0.25rem] cc-icons'>
{controller.isMutable ? ( {controller.isMutable ? (
<> <>
<MiniButton
title={!controller.isContentEditable ? 'Откатить к версии' : 'Переключитесь на неактуальную версию'}
disabled={controller.isContentEditable}
onClick={() => controller.restoreVersion()}
icon={<IconUpload size='1.25rem' className='icon-red' />}
/>
<MiniButton <MiniButton
title={controller.isContentEditable ? 'Создать версию' : 'Переключитесь на актуальную версию'} title={controller.isContentEditable ? 'Создать версию' : 'Переключитесь на актуальную версию'}
disabled={!controller.isContentEditable} disabled={!controller.isContentEditable}

View File

@ -66,6 +66,7 @@ interface IRSEditContext {
viewVersion: (version?: number, newTab?: boolean) => void; viewVersion: (version?: number, newTab?: boolean) => void;
createVersion: () => void; createVersion: () => void;
restoreVersion: () => void;
editVersions: () => void; editVersions: () => void;
moveUp: () => void; moveUp: () => void;
@ -171,6 +172,26 @@ export const RSEditState = ({
[router, model] [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( const handleCreateCst = useCallback(
(data: ICstCreateData) => { (data: ICstCreateData) => {
if (!model.schema) { if (!model.schema) {
@ -254,9 +275,14 @@ export const RSEditState = ({
if (!model.schema) { if (!model.schema) {
return; return;
} }
model.versionDelete(versionID, () => toast.success('Версия удалена')); model.versionDelete(versionID, () => {
toast.success('Версия удалена');
if (String(versionID) === model.versionID) {
viewVersion(undefined);
}
});
}, },
[model] [model, viewVersion]
); );
const handleUpdateVersion = useCallback( const handleUpdateVersion = useCallback(
@ -505,7 +531,8 @@ export const RSEditState = ({
deselectAll: () => setSelected([]), deselectAll: () => setSelected([]),
viewVersion, viewVersion,
createVersion: () => setShowCreateVersion(true), createVersion,
restoreVersion,
editVersions: () => setShowEditVersions(true), editVersions: () => setShowEditVersions(true),
moveUp, moveUp,