From 9ff6a92c4f4ea0777149af175011fd832bbb3775 Mon Sep 17 00:00:00 2001 From: IRBorisov <8611739+IRBorisov@users.noreply.github.com> Date: Sat, 18 May 2024 19:22:26 +0300 Subject: [PATCH] Implement restoreVersion --- .../apps/rsform/serializers/__init__.py | 2 +- .../apps/rsform/serializers/data_access.py | 57 +++++++++++++++++-- .../apps/rsform/tests/s_views/t_versions.py | 41 +++++++++++-- rsconcept/backend/apps/rsform/urls.py | 2 +- .../backend/apps/rsform/views/__init__.py | 2 +- .../backend/apps/rsform/views/constituents.py | 2 +- .../backend/apps/rsform/views/rsforms.py | 4 +- .../backend/apps/rsform/views/versions.py | 27 ++++++++- rsconcept/frontend/src/app/backendAPI.ts | 8 +++ .../frontend/src/context/RSFormContext.tsx | 23 +++++++- .../ManualsPage/items/HelpRSFormCard.tsx | 12 +++- .../ManualsPage/items/HelpRSFormMenu.tsx | 4 +- .../pages/ManualsPage/items/HelpVersions.tsx | 11 +++- .../RSFormPage/EditorRSForm/FormRSForm.tsx | 8 ++- .../src/pages/RSFormPage/RSEditContext.tsx | 33 ++++++++++- 15 files changed, 206 insertions(+), 30 deletions(-) diff --git a/rsconcept/backend/apps/rsform/serializers/__init__.py b/rsconcept/backend/apps/rsform/serializers/__init__.py index c61200eb..6dbba08a 100644 --- a/rsconcept/backend/apps/rsform/serializers/__init__.py +++ b/rsconcept/backend/apps/rsform/serializers/__init__.py @@ -16,7 +16,7 @@ from .data_access import ( RSFormParseSerializer, VersionSerializer, VersionCreateSerializer, - ConstituentaSerializer, + CstSerializer, CstTargetSerializer, CstMoveSerializer, CstSubstituteSerializer, diff --git a/rsconcept/backend/apps/rsform/serializers/data_access.py b/rsconcept/backend/apps/rsform/serializers/data_access.py index aeaacf1c..90eb9360 100644 --- a/rsconcept/backend/apps/rsform/serializers/data_access.py +++ b/rsconcept/backend/apps/rsform/serializers/data_access.py @@ -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. ''' diff --git a/rsconcept/backend/apps/rsform/tests/s_views/t_versions.py b/rsconcept/backend/apps/rsform/tests/s_views/t_versions.py index 544cd4bb..e775ec1a 100644 --- a/rsconcept/backend/apps/rsform/tests/s_views/t_versions.py +++ b/rsconcept/backend/apps/rsform/tests/s_views/t_versions.py @@ -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', diff --git a/rsconcept/backend/apps/rsform/urls.py b/rsconcept/backend/apps/rsform/urls.py index 9441dde3..8fab1ac4 100644 --- a/rsconcept/backend/apps/rsform/urls.py +++ b/rsconcept/backend/apps/rsform/urls.py @@ -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/', views.VersionAPIView.as_view()), path('versions//export-file', views.export_file), path('rsforms//versions/create', views.create_version), path('rsforms//versions/', views.retrieve_version), diff --git a/rsconcept/backend/apps/rsform/views/__init__.py b/rsconcept/backend/apps/rsform/views/__init__.py index 93d6b28d..db2574e3 100644 --- a/rsconcept/backend/apps/rsform/views/__init__.py +++ b/rsconcept/backend/apps/rsform/views/__init__.py @@ -7,7 +7,7 @@ from .library import ( ) from .constituents import ConstituentAPIView from .versions import ( - VersionAPIView, + VersionViewset, create_version, export_file, retrieve_version diff --git a/rsconcept/backend/apps/rsform/views/constituents.py b/rsconcept/backend/apps/rsform/views/constituents.py index 12d89445..5cbabd42 100644 --- a/rsconcept/backend/apps/rsform/views/constituents.py +++ b/rsconcept/backend/apps/rsform/views/constituents.py @@ -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() diff --git a/rsconcept/backend/apps/rsform/views/rsforms.py b/rsconcept/backend/apps/rsform/views/rsforms.py index 858fadc8..a7dd1e44 100644 --- a/rsconcept/backend/apps/rsform/views/rsforms.py +++ b/rsconcept/backend/apps/rsform/views/rsforms.py @@ -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 } ) diff --git a/rsconcept/backend/apps/rsform/views/versions.py b/rsconcept/backend/apps/rsform/views/versions.py index 030d646c..92803d64 100644 --- a/rsconcept/backend/apps/rsform/views/versions.py +++ b/rsconcept/backend/apps/rsform/views/versions.py @@ -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', diff --git a/rsconcept/frontend/src/app/backendAPI.ts b/rsconcept/frontend/src/app/backendAPI.ts index 9f4ae83b..6c885589 100644 --- a/rsconcept/frontend/src/app/backendAPI.ts +++ b/rsconcept/frontend/src/app/backendAPI.ts @@ -453,6 +453,14 @@ export function patchVersion(target: string, request: FrontPush) { }); } +export function patchRestoreVersion(target: string, request: FrontPull) { + 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}`, diff --git a/rsconcept/frontend/src/context/RSFormContext.tsx b/rsconcept/frontend/src/context/RSFormContext.tsx index fb7aa40d..72a31397 100644 --- a/rsconcept/frontend/src/context/RSFormContext.tsx +++ b/rsconcept/frontend/src/context/RSFormContext.tsx @@ -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(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) => { 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} diff --git a/rsconcept/frontend/src/pages/ManualsPage/items/HelpRSFormCard.tsx b/rsconcept/frontend/src/pages/ManualsPage/items/HelpRSFormCard.tsx index edd1f3be..956004de 100644 --- a/rsconcept/frontend/src/pages/ManualsPage/items/HelpRSFormCard.tsx +++ b/rsconcept/frontend/src/pages/ManualsPage/items/HelpRSFormCard.tsx @@ -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() {
  • Владелец обладает правом редактирования
  • Общедоступные схемы доступны для всех
  • Неизменные схемы редактируют только администраторы
  • -
  • Клонировать – создать копию схемы
  • +
  • Клонировать – создать копию схемы
  • Отслеживание – схема в персональном списке
  • Загрузить/Выгрузить – взаимодействие с Экстеор
  • Удалить – полностью удаляет схему из базы Портала
  • + +

    Версионирование

    +
  • Создать версию можно только из актуальной схемы
  • +
  • Загрузить версию в актуальную схему
  • +
  • Редактировать атрибуты версий
  • ); } diff --git a/rsconcept/frontend/src/pages/ManualsPage/items/HelpRSFormMenu.tsx b/rsconcept/frontend/src/pages/ManualsPage/items/HelpRSFormMenu.tsx index 7e6fcc6d..f490ff5d 100644 --- a/rsconcept/frontend/src/pages/ManualsPage/items/HelpRSFormMenu.tsx +++ b/rsconcept/frontend/src/pages/ManualsPage/items/HelpRSFormMenu.tsx @@ -50,7 +50,7 @@ function HelpRSFormMenu() { Поделиться – скопировать ссылку на схему
  • - Клонировать – создать копию схемы + Клонировать – создать копию схемы
  • Выгрузить – сохранить в файле формата Экстеор @@ -87,7 +87,7 @@ function HelpRSFormMenu() {

    - операции над концептуальной схемой описаны в{' '} + операции над концептуальной схемой описаны в{' '} .

    diff --git a/rsconcept/frontend/src/pages/ManualsPage/items/HelpVersions.tsx b/rsconcept/frontend/src/pages/ManualsPage/items/HelpVersions.tsx index 47388791..41fca240 100644 --- a/rsconcept/frontend/src/pages/ManualsPage/items/HelpVersions.tsx +++ b/rsconcept/frontend/src/pages/ManualsPage/items/HelpVersions.tsx @@ -1,3 +1,6 @@ +import LinkTopic from '@/components/ui/LinkTopic'; +import { HelpTopic } from '@/models/miscellaneous'; + function HelpVersions() { return (
    @@ -6,9 +9,11 @@ function HelpVersions() { Версионирование позволяет сохранить текущее состояние схемы под определенным именем (версией) и использовать ссылку на него для совместной работы. После создания версии ее содержание изменить нельзя

    -

    Владелец обладает правом редактирования названий и создания новых версий

    -

    Управление версиями происходит в Карточке схемы

    -

    Функция Поделиться включает версию в ссылку

    +
  • Владелец обладает правом редактирования названий и создания новых версий
  • +
  • + Управление версиями происходит в +
  • +
  • Функция Поделиться включает версию в ссылку
  • ); } diff --git a/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/FormRSForm.tsx b/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/FormRSForm.tsx index d2d7f2a7..3dc35b01 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/FormRSForm.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/FormRSForm.tsx @@ -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) { {controller.isMutable ? ( <> + controller.restoreVersion()} + icon={} + /> 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,