From 604578d9daba28d1efd1d362b458c8d5b523d4aa Mon Sep 17 00:00:00 2001 From: IRBorisov <8611739+IRBorisov@users.noreply.github.com> Date: Thu, 4 Apr 2024 14:34:25 +0300 Subject: [PATCH] Add option to clone only selected constituents --- TODO.txt | 37 ++++----- .../apps/rsform/serializers/__init__.py | 1 + .../apps/rsform/serializers/data_access.py | 5 ++ .../rsform/tests/s_views/EndpointTester.py | 4 + .../apps/rsform/tests/s_views/t_library.py | 24 ++++++ .../backend/apps/rsform/views/library.py | 33 ++++++-- .../backend/apps/rsform/views/rsforms.py | 83 +++++++++++++++---- .../frontend/src/context/LibraryContext.tsx | 6 +- .../src/dialogs/DlgCloneLibraryItem.tsx | 46 +++++----- rsconcept/frontend/src/models/rsform.ts | 7 ++ .../src/pages/RSFormPage/RSEditContext.tsx | 9 +- rsconcept/frontend/src/utils/backendAPI.ts | 3 +- 12 files changed, 188 insertions(+), 70 deletions(-) diff --git a/TODO.txt b/TODO.txt index 41a339bc..10069e38 100644 --- a/TODO.txt +++ b/TODO.txt @@ -1,28 +1,27 @@ -!! This is not complete list of todos !! -This list only contains global tech refactorings and tech debt +!! This is not complete list of TODOs !! For more specific TODOs see comments in code -[Functionality] -- landing page -- home page (user specific) -- export PDF -- блок нотификаций пользователей -- блок синтеза +[Functionality - PROGRESS] +- Landing page +- Home page (user specific) -- статический анализ схемы -- конфигурации правил для разных статусов +- Operational synthesis schema as LibraryItem ? -- Library organization, search and exploration. Consider new user experience -- поиск по содержимому КС в Библиотеке - -- private projects and permissions. Consider cooperative editing - -- draggable rows in constituents table +- Draggable rows in constituents table - Clickable IDs in RSEditor tooltips -- ARIA (accessibility considerations) - for now machine reading not supported +- Library organization, search and exploration. Consider new user experience +- Private projects and permissions. Consider cooperative editing +- Rework access setup: project-based, user-based, enable sharing. Prevent enumerating access to private schemas by default -- rework access setup: project-based, user-based, enable sharing. Prevent enumerating access to private schemas by default +[functionality - PLANNING] +- User notifications on edit - consider spam prevention and change aggregation +- Static analyzer for RSForm +- Content based search in Library + +- Export PDF (Items list, Graph) +- ARIA (accessibility considerations) - for now machine reading not supported +- Internationalization - at least english version. Consider react.intl [Tech] @@ -41,7 +40,7 @@ For more specific TODOs see comments in code [Research] Research and consider integration -- django-allauth +- django-allauth - consider supporting popular auth providers - drf-messages - radix-ui diff --git a/rsconcept/backend/apps/rsform/serializers/__init__.py b/rsconcept/backend/apps/rsform/serializers/__init__.py index 92ddfd28..c61200eb 100644 --- a/rsconcept/backend/apps/rsform/serializers/__init__.py +++ b/rsconcept/backend/apps/rsform/serializers/__init__.py @@ -11,6 +11,7 @@ from .basics import ( ) from .data_access import ( LibraryItemSerializer, + LibraryItemCloneSerializer, RSFormSerializer, RSFormParseSerializer, VersionSerializer, diff --git a/rsconcept/backend/apps/rsform/serializers/data_access.py b/rsconcept/backend/apps/rsform/serializers/data_access.py index e7b3aa81..8c6714d7 100644 --- a/rsconcept/backend/apps/rsform/serializers/data_access.py +++ b/rsconcept/backend/apps/rsform/serializers/data_access.py @@ -256,6 +256,11 @@ class CstListSerializer(serializers.Serializer): return attrs +class LibraryItemCloneSerializer(LibraryItemSerializer): + ''' Serializer: LibraryItem cloning. ''' + items = PKField(many=True, required=False, queryset=Constituenta.objects.all()) + + class CstMoveSerializer(CstListSerializer): ''' Serializer: Change constituenta position. ''' move_to = serializers.IntegerField() diff --git a/rsconcept/backend/apps/rsform/tests/s_views/EndpointTester.py b/rsconcept/backend/apps/rsform/tests/s_views/EndpointTester.py index 95306109..bee52aa6 100644 --- a/rsconcept/backend/apps/rsform/tests/s_views/EndpointTester.py +++ b/rsconcept/backend/apps/rsform/tests/s_views/EndpointTester.py @@ -80,6 +80,10 @@ class EndpointTester(APITestCase): response = self.execute(data, **kwargs) self.assertEqual(response.status_code, status.HTTP_200_OK) + def assertCreated(self, data=None, **kwargs): + response = self.execute(data, **kwargs) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + def assertBadData(self, data=None, **kwargs): response = self.execute(data, **kwargs) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/rsconcept/backend/apps/rsform/tests/s_views/t_library.py b/rsconcept/backend/apps/rsform/tests/s_views/t_library.py index 401a2b17..afc54252 100644 --- a/rsconcept/backend/apps/rsform/tests/s_views/t_library.py +++ b/rsconcept/backend/apps/rsform/tests/s_views/t_library.py @@ -31,6 +31,7 @@ class TestLibraryViewset(EndpointTester): alias='T3', is_common=True ) + self.invalid_item = 1337 + self.common.pk @decl_endpoint('/api/library', method='post') @@ -49,6 +50,7 @@ class TestLibraryViewset(EndpointTester): @decl_endpoint('/api/library/{item}', method='patch') def test_update(self): data = {'id': self.unowned.id, 'title': 'New title'} + self.assertNotFound(data, item=self.invalid_item) self.assertForbidden(data, item=self.unowned.id) data = {'id': self.owned.id, 'title': 'New title'} @@ -71,6 +73,7 @@ class TestLibraryViewset(EndpointTester): @decl_endpoint('/api/library/{item}/claim', method='post') def test_claim(self): + self.assertNotFound(item=self.invalid_item) self.assertForbidden(item=self.owned.id) self.owned.is_common = True @@ -127,6 +130,7 @@ class TestLibraryViewset(EndpointTester): @decl_endpoint('/api/library/{item}/subscribe', method='post') def test_subscriptions(self): + self.assertNotFound(item=self.invalid_item) response = self.client.delete(f'/api/library/{self.unowned.id}/unsubscribe') self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertFalse(self.user in self.unowned.subscribers()) @@ -174,11 +178,31 @@ class TestLibraryViewset(EndpointTester): ) data = {'title': 'Title1337'} + self.assertNotFound(data, item=self.invalid_item) + self.assertCreated(data, item=self.unowned.id) + + data = {'title': 'Title1338'} response = self.execute(data, item=self.owned.id) self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.data['title'], data['title']) + self.assertEqual(len(response.data['items']), 2) self.assertEqual(response.data['items'][0]['alias'], x12.alias) self.assertEqual(response.data['items'][0]['term_raw'], x12.term_raw) self.assertEqual(response.data['items'][0]['term_resolved'], x12.term_resolved) self.assertEqual(response.data['items'][1]['term_raw'], d2.term_raw) self.assertEqual(response.data['items'][1]['term_resolved'], d2.term_resolved) + + data = {'title': 'Title1340', 'items': []} + response = self.execute(data, item=self.owned.id) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data['title'], data['title']) + self.assertEqual(len(response.data['items']), 0) + + data = {'title': 'Title1341', 'items': [x12.pk]} + response = self.execute(data, item=self.owned.id) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data['title'], data['title']) + self.assertEqual(len(response.data['items']), 1) + self.assertEqual(response.data['items'][0]['alias'], x12.alias) + self.assertEqual(response.data['items'][0]['term_raw'], x12.term_raw) + self.assertEqual(response.data['items'][0]['term_resolved'], x12.term_resolved) diff --git a/rsconcept/backend/apps/rsform/views/library.py b/rsconcept/backend/apps/rsform/views/library.py index 3911aad5..8288dee5 100644 --- a/rsconcept/backend/apps/rsform/views/library.py +++ b/rsconcept/backend/apps/rsform/views/library.py @@ -81,9 +81,11 @@ class LibraryViewSet(viewsets.ModelViewSet): @extend_schema( summary='clone item including contents', tags=['Library'], - request=s.LibraryItemSerializer, + request=s.LibraryItemCloneSerializer, responses={ c.HTTP_201_CREATED: s.RSFormParseSerializer, + c.HTTP_400_BAD_REQUEST: None, + c.HTTP_403_FORBIDDEN: None, c.HTTP_404_NOT_FOUND: None } ) @@ -91,7 +93,7 @@ class LibraryViewSet(viewsets.ModelViewSet): @action(detail=True, methods=['post'], url_path='clone') def clone(self, request: Request, pk): ''' Endpoint: Create deep copy of library item. ''' - serializer = s.LibraryItemSerializer(data=request.data) + serializer = s.LibraryItemCloneSerializer(data=request.data) serializer.is_valid(raise_exception=True) item = self._get_item() if item.item_type == m.LibraryItemType.RSFORM: @@ -104,6 +106,13 @@ class LibraryViewSet(viewsets.ModelViewSet): clone_data['comment'] = serializer.validated_data.get('comment', '') clone_data['is_common'] = serializer.validated_data.get('is_common', False) clone_data['is_canonical'] = serializer.validated_data.get('is_canonical', False) + if 'items' in request.data: + filtered_items = [] + for cst in clone_data['items']: + if cst['entityUID'] in request.data['items']: + filtered_items.append(cst) + clone_data['items'] = filtered_items + clone = s.RSFormTRSSerializer(data=clone_data, context={'load_meta': True}) clone.is_valid(raise_exception=True) new_schema = clone.save() @@ -111,13 +120,17 @@ class LibraryViewSet(viewsets.ModelViewSet): status=c.HTTP_201_CREATED, data=s.RSFormParseSerializer(new_schema.item).data ) - return Response(status=c.HTTP_404_NOT_FOUND) + return Response(status=c.HTTP_400_BAD_REQUEST) @extend_schema( summary='claim item', tags=['Library'], request=None, - responses={c.HTTP_200_OK: s.LibraryItemSerializer} + responses={ + c.HTTP_200_OK: s.LibraryItemSerializer, + c.HTTP_403_FORBIDDEN: None, + c.HTTP_404_NOT_FOUND: None + } ) @transaction.atomic @action(detail=True, methods=['post']) @@ -139,7 +152,11 @@ class LibraryViewSet(viewsets.ModelViewSet): summary='subscribe to item', tags=['Library'], request=None, - responses={c.HTTP_204_NO_CONTENT: None} + responses={ + c.HTTP_204_NO_CONTENT: None, + c.HTTP_403_FORBIDDEN: None, + c.HTTP_404_NOT_FOUND: None + } ) @action(detail=True, methods=['post']) def subscribe(self, request: Request, pk): @@ -152,7 +169,11 @@ class LibraryViewSet(viewsets.ModelViewSet): summary='unsubscribe from item', tags=['Library'], request=None, - responses={c.HTTP_204_NO_CONTENT: None}, + responses={ + c.HTTP_204_NO_CONTENT: None, + c.HTTP_403_FORBIDDEN: None, + c.HTTP_404_NOT_FOUND: None + }, ) @action(detail=True, methods=['delete']) def unsubscribe(self, request: Request, pk): diff --git a/rsconcept/backend/apps/rsform/views/rsforms.py b/rsconcept/backend/apps/rsform/views/rsforms.py index f801e3d2..f391ffea 100644 --- a/rsconcept/backend/apps/rsform/views/rsforms.py +++ b/rsconcept/backend/apps/rsform/views/rsforms.py @@ -41,7 +41,11 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr summary='create constituenta', tags=['Constituenta'], request=s.CstCreateSerializer, - responses={c.HTTP_201_CREATED: s.NewCstResponse} + responses={ + c.HTTP_201_CREATED: s.NewCstResponse, + c.HTTP_403_FORBIDDEN: None, + c.HTTP_404_NOT_FOUND: None + } ) @action(detail=True, methods=['post'], url_path='cst-create') def cst_create(self, request: Request, pk): @@ -69,7 +73,11 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr summary='produce the structure of a given constituenta', tags=['RSForm'], request=s.CstTargetSerializer, - responses={c.HTTP_200_OK: s.NewMultiCstResponse} + responses={ + c.HTTP_200_OK: s.NewMultiCstResponse, + c.HTTP_403_FORBIDDEN: None, + c.HTTP_404_NOT_FOUND: None + } ) @action(detail=True, methods=['patch'], url_path='cst-produce-structure') def produce_structure(self, request: Request, pk): @@ -101,7 +109,11 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr summary='rename constituenta', tags=['Constituenta'], request=s.CstRenameSerializer, - responses={c.HTTP_200_OK: s.NewCstResponse} + responses={ + c.HTTP_200_OK: s.NewCstResponse, + c.HTTP_403_FORBIDDEN: None, + c.HTTP_404_NOT_FOUND: None + } ) @transaction.atomic @action(detail=True, methods=['patch'], url_path='cst-rename') @@ -135,7 +147,11 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr summary='substitute constituenta', tags=['RSForm'], request=s.CstSubstituteSerializer, - responses={c.HTTP_200_OK: s.RSFormParseSerializer} + responses={ + c.HTTP_200_OK: s.RSFormParseSerializer, + c.HTTP_403_FORBIDDEN: None, + c.HTTP_404_NOT_FOUND: None + } ) @transaction.atomic @action(detail=True, methods=['patch'], url_path='cst-substitute') @@ -161,7 +177,11 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr summary='delete constituents', tags=['RSForm'], request=s.CstListSerializer, - responses={c.HTTP_200_OK: s.RSFormParseSerializer} + 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='cst-delete-multiple') def cst_delete_multiple(self, request: Request, pk): @@ -183,7 +203,11 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr summary='move constituenta', tags=['RSForm'], request=s.CstMoveSerializer, - responses={c.HTTP_200_OK: s.RSFormParseSerializer} + 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='cst-moveto') def cst_moveto(self, request: Request, pk): @@ -208,7 +232,11 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr summary='reset aliases, update expressions and references', tags=['RSForm'], request=None, - responses={c.HTTP_200_OK: s.RSFormParseSerializer} + 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='reset-aliases') def reset_aliases(self, request: Request, pk): @@ -224,7 +252,11 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr summary='load data from TRS file', tags=['RSForm'], request=s.RSFormUploadSerializer, - responses={c.HTTP_200_OK: s.RSFormParseSerializer} + 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='load-trs') def load_trs(self, request: Request, pk): @@ -251,7 +283,10 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr summary='get all constituents data from DB', tags=['RSForm'], request=None, - responses={c.HTTP_200_OK: s.RSFormSerializer} + responses={ + c.HTTP_200_OK: s.RSFormSerializer, + c.HTTP_404_NOT_FOUND: None + } ) @action(detail=True, methods=['get']) def contents(self, request: Request, pk): @@ -266,7 +301,10 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr summary='get all constituents data and parses', tags=['RSForm'], request=None, - responses={c.HTTP_200_OK: s.RSFormParseSerializer} + responses={ + c.HTTP_200_OK: s.RSFormParseSerializer, + c.HTTP_404_NOT_FOUND: None + } ) @action(detail=True, methods=['get']) def details(self, request: Request, pk): @@ -281,7 +319,10 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr summary='check RSLang expression', tags=['RSForm', 'FormalLanguage'], request=s.ExpressionSerializer, - responses={c.HTTP_200_OK: s.ExpressionParseSerializer}, + responses={ + c.HTTP_200_OK: s.ExpressionParseSerializer, + c.HTTP_404_NOT_FOUND: None + }, ) @action(detail=True, methods=['post']) def check(self, request: Request, pk): @@ -300,7 +341,10 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr summary='resolve text with references', tags=['RSForm', 'NaturalLanguage'], request=s.TextSerializer, - responses={c.HTTP_200_OK: s.ResolverSerializer} + responses={ + c.HTTP_200_OK: s.ResolverSerializer, + c.HTTP_404_NOT_FOUND: None + } ) @action(detail=True, methods=['post']) def resolve(self, request: Request, pk): @@ -319,7 +363,10 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr summary='export as TRS file', tags=['RSForm'], request=None, - responses={(c.HTTP_200_OK, 'application/zip'): bytes} + responses={ + (c.HTTP_200_OK, 'application/zip'): bytes, + c.HTTP_404_NOT_FOUND: None + } ) @action(detail=True, methods=['get'], url_path='export-trs') def export_trs(self, request: Request, pk): @@ -341,7 +388,10 @@ class TrsImportView(views.APIView): summary='import TRS file into RSForm', tags=['RSForm'], request=s.FileSerializer, - responses={c.HTTP_201_CREATED: s.LibraryItemSerializer} + responses={ + c.HTTP_201_CREATED: s.LibraryItemSerializer, + c.HTTP_403_FORBIDDEN: None + } ) def post(self, request: Request): data = utils.read_zipped_json(request.FILES['file'].file, utils.EXTEOR_INNER_FILENAME) @@ -364,7 +414,10 @@ class TrsImportView(views.APIView): summary='create new RSForm empty or from file', tags=['RSForm'], request=s.LibraryItemSerializer, - responses={c.HTTP_201_CREATED: s.LibraryItemSerializer} + responses={ + c.HTTP_201_CREATED: s.LibraryItemSerializer, + c.HTTP_403_FORBIDDEN: None + } ) @api_view(['POST']) def create_rsform(request: Request): diff --git a/rsconcept/frontend/src/context/LibraryContext.tsx b/rsconcept/frontend/src/context/LibraryContext.tsx index f7b44d1c..8dee2198 100644 --- a/rsconcept/frontend/src/context/LibraryContext.tsx +++ b/rsconcept/frontend/src/context/LibraryContext.tsx @@ -6,7 +6,7 @@ import { ErrorData } from '@/components/info/InfoError'; import { ILibraryItem } from '@/models/library'; import { matchLibraryItem } from '@/models/libraryAPI'; import { ILibraryFilter } from '@/models/miscellaneous'; -import { IRSForm, IRSFormCreateData, IRSFormData } from '@/models/rsform'; +import { IRSForm, IRSFormCloneData, IRSFormCreateData, IRSFormData } from '@/models/rsform'; import { loadRSFormData } from '@/models/rsformAPI'; import { DataCallback, @@ -31,7 +31,7 @@ interface ILibraryContext { applyFilter: (params: ILibraryFilter) => ILibraryItem[]; retrieveTemplate: (templateID: number, callback: (schema: IRSForm) => void) => void; createItem: (data: IRSFormCreateData, callback?: DataCallback) => void; - cloneItem: (target: number, data: IRSFormCreateData, callback: DataCallback) => void; + cloneItem: (target: number, data: IRSFormCloneData, callback: DataCallback) => void; destroyItem: (target: number, callback?: () => void) => void; localUpdateItem: (data: ILibraryItem) => void; @@ -194,7 +194,7 @@ export const LibraryState = ({ children }: LibraryStateProps) => { ); const cloneItem = useCallback( - (target: number, data: IRSFormCreateData, callback: DataCallback) => { + (target: number, data: IRSFormCloneData, callback: DataCallback) => { if (!user) { return; } diff --git a/rsconcept/frontend/src/dialogs/DlgCloneLibraryItem.tsx b/rsconcept/frontend/src/dialogs/DlgCloneLibraryItem.tsx index ac55ac5b..eb4a571f 100644 --- a/rsconcept/frontend/src/dialogs/DlgCloneLibraryItem.tsx +++ b/rsconcept/frontend/src/dialogs/DlgCloneLibraryItem.tsx @@ -1,7 +1,7 @@ 'use client'; import clsx from 'clsx'; -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { toast } from 'react-toastify'; import { urls } from '@/app/urls'; @@ -13,43 +13,38 @@ import { useLibrary } from '@/context/LibraryContext'; import { useConceptNavigation } from '@/context/NavigationContext'; import { ILibraryItem } from '@/models/library'; import { cloneTitle } from '@/models/libraryAPI'; -import { IRSFormCreateData } from '@/models/rsform'; +import { ConstituentaID, IRSFormCloneData } from '@/models/rsform'; interface DlgCloneLibraryItemProps extends Pick { base: ILibraryItem; + selected: ConstituentaID[]; + totalCount: number; } -function DlgCloneLibraryItem({ hideWindow, base }: DlgCloneLibraryItemProps) { +function DlgCloneLibraryItem({ hideWindow, base, selected, totalCount }: DlgCloneLibraryItemProps) { const router = useConceptNavigation(); - const [title, setTitle] = useState(''); - const [alias, setAlias] = useState(''); - const [comment, setComment] = useState(''); - const [common, setCommon] = useState(false); - const [canonical, setCanonical] = useState(false); + const [title, setTitle] = useState(cloneTitle(base)); + const [alias, setAlias] = useState(base.alias); + const [comment, setComment] = useState(base.comment); + const [common, setCommon] = useState(base.is_common); + const [onlySelected, setOnlySelected] = useState(false); const { cloneItem } = useLibrary(); const canSubmit = useMemo(() => title !== '' && alias !== '', [title, alias]); - useEffect(() => { - if (base) { - setTitle(cloneTitle(base)); - setAlias(base.alias); - setComment(base.comment); - setCommon(base.is_common); - setCanonical(false); - } - }, [base, base?.title, base?.alias, base?.comment, base?.is_common]); - function handleSubmit() { - const data: IRSFormCreateData = { + const data: IRSFormCloneData = { item_type: base.item_type, title: title, alias: alias, comment: comment, is_common: common, - is_canonical: canonical + is_canonical: false }; + if (onlySelected) { + data.items = selected; + } cloneItem(base.id, data, newSchema => { toast.success(`Копия создана: ${newSchema.alias}`); router.push(urls.schema(newSchema.id)); @@ -78,11 +73,12 @@ function DlgCloneLibraryItem({ hideWindow, base }: DlgCloneLibraryItemProps) { className='max-w-sm' onChange={event => setAlias(event.target.value)} /> -