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,
VersionSerializer,
VersionCreateSerializer,
ConstituentaSerializer,
CstSerializer,
CstTargetSerializer,
CstMoveSerializer,
CstSubstituteSerializer,

View File

@ -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. '''

View File

@ -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',

View File

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

View File

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

View File

@ -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()

View File

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

View File

@ -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',

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) {
AxiosDelete({
title: `Version id=${target}`,

View File

@ -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}

View File

@ -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>);
}

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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}

View File

@ -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,