From fc05d53d310651f15b55b35e6b59a86d7cc43b19 Mon Sep 17 00:00:00 2001 From: Ivan <8611739+IRBorisov@users.noreply.github.com> Date: Wed, 30 Jul 2025 21:50:14 +0300 Subject: [PATCH] F: Add clone-schema to OSS functions --- .../backend/apps/library/views/library.py | 21 +++--- .../apps/oss/models/OperationSchema.py | 1 + .../backend/apps/oss/serializers/__init__.py | 1 + .../apps/oss/serializers/data_access.py | 20 ++++++ .../apps/oss/tests/s_views/t_operations.py | 43 +++++++++++ rsconcept/backend/apps/oss/views/oss.py | 71 +++++++++++++++++++ rsconcept/backend/shared/messages.py | 4 ++ rsconcept/frontend/src/components/icons.tsx | 1 + .../frontend/src/features/oss/backend/api.ts | 13 ++++ .../src/features/oss/backend/types.ts | 7 ++ .../features/oss/backend/use-clone-schema.ts | 25 +++++++ .../src/features/oss/models/oss-layout-api.ts | 15 ++++ .../context-menu/menu-operation.tsx | 56 ++++++++++++++- 13 files changed, 264 insertions(+), 14 deletions(-) create mode 100644 rsconcept/frontend/src/features/oss/backend/use-clone-schema.ts diff --git a/rsconcept/backend/apps/library/views/library.py b/rsconcept/backend/apps/library/views/library.py index 959a757b..85d5ed5a 100644 --- a/rsconcept/backend/apps/library/views/library.py +++ b/rsconcept/backend/apps/library/views/library.py @@ -159,18 +159,17 @@ class LibraryViewSet(viewsets.ModelViewSet): serializer.is_valid(raise_exception=True) data = serializer.validated_data['item_data'] - clone = deepcopy(item) - clone.pk = None - clone.owner = cast(User, self.request.user) - clone.title = data['title'] - clone.alias = data.get('alias', '') - clone.description = data.get('description', '') - clone.visible = data.get('visible', True) - clone.read_only = False - clone.access_policy = data.get('access_policy', m.AccessPolicy.PUBLIC) - clone.location = data.get('location', m.LocationHead.USER) - with transaction.atomic(): + clone = deepcopy(item) + clone.pk = None + clone.owner = cast(User, self.request.user) + clone.title = data['title'] + clone.alias = data.get('alias', '') + clone.description = data.get('description', '') + clone.visible = data.get('visible', True) + clone.read_only = False + clone.access_policy = data.get('access_policy', m.AccessPolicy.PUBLIC) + clone.location = data.get('location', m.LocationHead.USER) clone.save() need_filter = 'items' in request.data and len(request.data['items']) > 0 for cst in RSForm(item).constituents(): diff --git a/rsconcept/backend/apps/oss/models/OperationSchema.py b/rsconcept/backend/apps/oss/models/OperationSchema.py index 8cd3b039..2f7114bf 100644 --- a/rsconcept/backend/apps/oss/models/OperationSchema.py +++ b/rsconcept/backend/apps/oss/models/OperationSchema.py @@ -56,6 +56,7 @@ class OperationSchema: def refresh_from_db(self) -> None: ''' Model wrapper. ''' self.model.refresh_from_db() + self.cache = OssCache(self) def operations(self) -> QuerySet[Operation]: ''' Get QuerySet containing all operations of current OSS. ''' diff --git a/rsconcept/backend/apps/oss/serializers/__init__.py b/rsconcept/backend/apps/oss/serializers/__init__.py index 31517346..f29fd79a 100644 --- a/rsconcept/backend/apps/oss/serializers/__init__.py +++ b/rsconcept/backend/apps/oss/serializers/__init__.py @@ -4,6 +4,7 @@ from .basics import LayoutSerializer, SubstitutionExSerializer from .data_access import ( ArgumentSerializer, BlockSerializer, + CloneSchemaSerializer, CreateBlockSerializer, CreateSchemaSerializer, CreateSynthesisSerializer, diff --git a/rsconcept/backend/apps/oss/serializers/data_access.py b/rsconcept/backend/apps/oss/serializers/data_access.py index fd916371..204fa1a4 100644 --- a/rsconcept/backend/apps/oss/serializers/data_access.py +++ b/rsconcept/backend/apps/oss/serializers/data_access.py @@ -217,6 +217,26 @@ class CreateSchemaSerializer(StrictSerializer): return attrs +class CloneSchemaSerializer(StrictSerializer): + ''' Serializer: Clone schema. ''' + layout = serializers.ListField(child=NodeSerializer()) + source_operation = PKField(many=False, queryset=Operation.objects.all()) + position = PositionSerializer() + + def validate(self, attrs): + oss = cast(LibraryItem, self.context['oss']) + source_operation = cast(Operation, attrs['source_operation']) + if source_operation.oss_id != oss.pk: + raise serializers.ValidationError({ + 'source_operation': msg.operationNotInOSS() + }) + if source_operation.result is None: + raise serializers.ValidationError({ + 'source_operation': msg.operationResultEmpty(source_operation.alias) + }) + return attrs + + class ImportSchemaSerializer(StrictSerializer): ''' Serializer: Import schema to new operation. ''' layout = serializers.ListField(child=NodeSerializer()) diff --git a/rsconcept/backend/apps/oss/tests/s_views/t_operations.py b/rsconcept/backend/apps/oss/tests/s_views/t_operations.py index d1ad5c8f..074ef879 100644 --- a/rsconcept/backend/apps/oss/tests/s_views/t_operations.py +++ b/rsconcept/backend/apps/oss/tests/s_views/t_operations.py @@ -123,6 +123,49 @@ class TestOssOperations(EndpointTester): self.executeCreated(data=data, item=self.unowned_id) + @decl_endpoint('/api/oss/{item}/clone-schema', method='post') + def test_clone_schema(self): + self.populateData() + + data = { + 'source_operation': self.operation1.pk, + 'layout': self.layout_data, + 'position': { + 'x': 2, + 'y': 2, + 'width': 400, + 'height': 60 + } + } + self.executeNotFound(data=data, item=self.invalid_id) + self.executeForbidden(data=data, item=self.unowned_id) + + response = self.executeCreated(data=data, item=self.owned_id) + self.assertIn('new_operation', response.data) + self.assertIn('oss', response.data) + new_operation_id = response.data['new_operation'] + oss_data = response.data['oss'] + new_operation = next(op for op in oss_data['operations'] if op['id'] == new_operation_id) + self.assertEqual(new_operation['operation_type'], OperationType.INPUT) + self.assertTrue(new_operation['alias'].startswith('+')) + self.assertTrue(new_operation['title'].startswith('+')) + self.assertIsNotNone(new_operation['result']) + self.assertEqual(new_operation['parent'], None) + + layout = oss_data['layout'] + operation_node = [item for item in layout if item['nodeID'] == 'o' + str(new_operation_id)][0] + self.assertEqual(operation_node['x'], data['position']['x']) + self.assertEqual(operation_node['y'], data['position']['y']) + self.assertEqual(operation_node['width'], data['position']['width']) + self.assertEqual(operation_node['height'], data['position']['height']) + + new_schema = LibraryItem.objects.get(pk=new_operation['result']) + self.assertEqual(new_schema.alias, new_operation['alias']) + self.assertEqual(new_schema.title, new_operation['title']) + self.assertEqual(new_schema.description, new_operation['description']) + self.assertEqual(self.ks1.constituents().count(), RSForm(new_schema).constituents().count()) + + @decl_endpoint('/api/oss/{item}/create-schema', method='post') def test_create_schema_parent(self): self.populateData() diff --git a/rsconcept/backend/apps/oss/views/oss.py b/rsconcept/backend/apps/oss/views/oss.py index 240e8427..0911bf90 100644 --- a/rsconcept/backend/apps/oss/views/oss.py +++ b/rsconcept/backend/apps/oss/views/oss.py @@ -64,6 +64,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev 'delete_block', 'move_items', 'create_schema', + 'clone_schema', 'import_schema', 'create_synthesis', 'update_operation', @@ -329,6 +330,76 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev ) + @extend_schema( + summary='clone conceptual schema - result of a target operation', + tags=['OSS'], + request=s.CloneSchemaSerializer(), + responses={ + c.HTTP_201_CREATED: s.OperationCreatedResponse, + c.HTTP_400_BAD_REQUEST: None, + c.HTTP_403_FORBIDDEN: None, + c.HTTP_404_NOT_FOUND: None + } + ) + @action(detail=True, methods=['post'], url_path='clone-schema') + def clone_schema(self, request: Request, pk) -> HttpResponse: + ''' Clone schema. ''' + serializer = s.CloneSchemaSerializer( + data=request.data, + context={'oss': self.get_object()} + ) + serializer.is_valid(raise_exception=True) + + oss = m.OperationSchema(self.get_object()) + layout = serializer.validated_data['layout'] + position = serializer.validated_data['position'] + with transaction.atomic(): + source = cast(m.Operation, serializer.validated_data['source_operation']) + alias = '+' + source.alias + title = '+' + source.title + source_schema = RSForm(cast(LibraryItem, source.result)) + + new_schema = deepcopy(source_schema.model) + new_schema.pk = None + new_schema.owner = oss.model.owner + new_schema.title = title + new_schema.alias = alias + new_schema.save() + + for cst in source_schema.constituents(): + cst.pk = None + cst.schema = new_schema + cst.save() + + new_operation = source + new_operation.pk = None + new_operation.alias = alias + new_operation.title = title + new_operation.operation_type = m.OperationType.INPUT + new_operation.result = None + new_operation.save() + + layout.append({ + 'nodeID': 'o' + str(new_operation.pk), + 'x': position['x'], + 'y': position['y'], + 'width': position['width'], + 'height': position['height'] + }) + oss.refresh_from_db() + oss.set_input(new_operation.pk, new_schema) + oss.update_layout(layout) + oss.save(update_fields=['time_update']) + + return Response( + status=c.HTTP_201_CREATED, + data={ + 'new_operation': new_operation.pk, + 'oss': s.OperationSchemaSerializer(oss.model).data + } + ) + + @extend_schema( summary='import conceptual schema to new OSS operation', tags=['OSS'], diff --git a/rsconcept/backend/shared/messages.py b/rsconcept/backend/shared/messages.py index 8a0e9d81..74d6ba74 100644 --- a/rsconcept/backend/shared/messages.py +++ b/rsconcept/backend/shared/messages.py @@ -90,6 +90,10 @@ def operationNotSynthesis(title: str): return f'Операция не является Синтезом: {title}' +def operationResultEmpty(title: str): + return f'Результат операции пуст: {title}' + + def operationResultNotEmpty(title: str): return f'Результат операции не пуст: {title}' diff --git a/rsconcept/frontend/src/components/icons.tsx b/rsconcept/frontend/src/components/icons.tsx index a9300ff7..268ae8a7 100644 --- a/rsconcept/frontend/src/components/icons.tsx +++ b/rsconcept/frontend/src/components/icons.tsx @@ -129,6 +129,7 @@ export { BiStopCircle as IconStatusIncalculable } from 'react-icons/bi'; export { BiPauseCircle as IconStatusProperty } from 'react-icons/bi'; export { LuPower as IconKeepAliasOn } from 'react-icons/lu'; export { LuPowerOff as IconKeepAliasOff } from 'react-icons/lu'; +export { VscReferences as IconPhantom } from 'react-icons/vsc'; // ===== Domain actions ===== export { BiUpvote as IconMoveUp } from 'react-icons/bi'; diff --git a/rsconcept/frontend/src/features/oss/backend/api.ts b/rsconcept/frontend/src/features/oss/backend/api.ts index 01cb8744..3c58240e 100644 --- a/rsconcept/frontend/src/features/oss/backend/api.ts +++ b/rsconcept/frontend/src/features/oss/backend/api.ts @@ -6,6 +6,7 @@ import { infoMsg } from '@/utils/labels'; import { type IBlockCreatedResponse, + type ICloneSchemaDTO, type IConstituentaReference, type ICreateBlockDTO, type ICreateSchemaDTO, @@ -98,6 +99,18 @@ export const ossApi = { } } }), + cloneSchema: ({ itemID, data }: { itemID: number; data: ICloneSchemaDTO }) => + axiosPost({ + schema: schemaOperationCreatedResponse, + endpoint: `/api/oss/${itemID}/clone-schema`, + request: { + data: data, + successMessage: response => { + const alias = response.oss.operations.find(op => op.id === response.new_operation)?.alias; + return infoMsg.newOperation(alias ?? 'ОШИБКА'); + } + } + }), createSynthesis: ({ itemID, data }: { itemID: number; data: ICreateSynthesisDTO }) => axiosPost({ schema: schemaOperationCreatedResponse, diff --git a/rsconcept/frontend/src/features/oss/backend/types.ts b/rsconcept/frontend/src/features/oss/backend/types.ts index f0807637..b8d373c5 100644 --- a/rsconcept/frontend/src/features/oss/backend/types.ts +++ b/rsconcept/frontend/src/features/oss/backend/types.ts @@ -50,6 +50,7 @@ export type IMoveItemsDTO = z.infer; export type ICreateSchemaDTO = z.infer; export type ICreateSynthesisDTO = z.infer; export type IImportSchemaDTO = z.infer; +export type ICloneSchemaDTO = z.infer; /** Represents data response when creating {@link IOperation}. */ export type IOperationCreatedResponse = z.infer; @@ -187,6 +188,12 @@ export const schemaCreateSchema = z.strictObject({ position: schemaPosition }); +export const schemaCloneSchema = z.strictObject({ + layout: schemaOssLayout, + source_operation: z.number(), + position: schemaPosition +}); + export const schemaCreateSynthesis = z.strictObject({ layout: schemaOssLayout, item_data: schemaOperationData, diff --git a/rsconcept/frontend/src/features/oss/backend/use-clone-schema.ts b/rsconcept/frontend/src/features/oss/backend/use-clone-schema.ts new file mode 100644 index 00000000..b26b7676 --- /dev/null +++ b/rsconcept/frontend/src/features/oss/backend/use-clone-schema.ts @@ -0,0 +1,25 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { useUpdateTimestamp } from '@/features/library/backend/use-update-timestamp'; + +import { KEYS } from '@/backend/configuration'; + +import { ossApi } from './api'; +import { type ICloneSchemaDTO } from './types'; + +export const useCloneSchema = () => { + const client = useQueryClient(); + const { updateTimestamp } = useUpdateTimestamp(); + const mutation = useMutation({ + mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'clone-schema'], + mutationFn: ossApi.cloneSchema, + onSuccess: data => { + updateTimestamp(data.oss.id, data.oss.time_update); + client.setQueryData(ossApi.getOssQueryOptions({ itemID: data.oss.id }).queryKey, data.oss); + }, + onError: () => client.invalidateQueries() + }); + return { + cloneSchema: (data: { itemID: number; data: ICloneSchemaDTO }) => mutation.mutateAsync(data) + }; +}; diff --git a/rsconcept/frontend/src/features/oss/models/oss-layout-api.ts b/rsconcept/frontend/src/features/oss/models/oss-layout-api.ts index 23690e1c..22b2e9a8 100644 --- a/rsconcept/frontend/src/features/oss/models/oss-layout-api.ts +++ b/rsconcept/frontend/src/features/oss/models/oss-layout-api.ts @@ -106,6 +106,21 @@ export class LayoutManager { return result; } + /** Calculate insert position for a new clone of {@link IOperation} */ + newClonePosition(targetID: string): Rectangle2D | null { + const targetNode = this.layout.find(pos => pos.nodeID === targetID); + if (!targetNode) { + return null; + } else { + return { + x: targetNode.x + targetNode.width / 2 + GRID_SIZE, + y: targetNode.y + targetNode.height / 2 + GRID_SIZE, + width: OPERATION_NODE_WIDTH, + height: OPERATION_NODE_HEIGHT + }; + } + } + /** Update layout when parent changes */ onChangeParent(targetID: string, newParent: string | null) { const targetNode = this.layout.find(pos => pos.nodeID === targetID); diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/context-menu/menu-operation.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/context-menu/menu-operation.tsx index ae3ede5b..3a951f82 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/context-menu/menu-operation.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/context-menu/menu-operation.tsx @@ -2,20 +2,24 @@ import { toast } from 'react-toastify'; import { useLibrary } from '@/features/library/backend/use-library'; +import { useCloneSchema } from '@/features/oss/backend/use-clone-schema'; import { DropdownButton } from '@/components/dropdown'; import { IconChild, + IconClone, IconConnect, IconDestroy, IconEdit2, IconExecute, IconNewRSForm, + IconPhantom, IconRSForm } from '@/components/icons'; import { useDialogsStore } from '@/stores/dialogs'; +import { PARAMETER } from '@/utils/constants'; import { errorMsg } from '@/utils/labels'; -import { prepareTooltip } from '@/utils/utils'; +import { notImplemented, prepareTooltip } from '@/utils/utils'; import { OperationType } from '../../../../backend/types'; import { useCreateInput } from '../../../../backend/use-create-input'; @@ -33,12 +37,13 @@ interface MenuOperationProps { export function MenuOperation({ operation, onHide }: MenuOperationProps) { const { items: libraryItems } = useLibrary(); - const { schema, navigateOperationSchema, isMutable, canDeleteOperation } = useOssEdit(); + const { schema, setSelected, navigateOperationSchema, isMutable, canDeleteOperation } = useOssEdit(); const isProcessing = useMutatingOss(); const getLayout = useGetLayout(); const { createInput: inputCreate } = useCreateInput(); const { executeOperation: operationExecute } = useExecuteOperation(); + const { cloneSchema } = useCloneSchema(); const showEditInput = useDialogsStore(state => state.showChangeInputSchema); const showRelocateConstituents = useDialogsStore(state => state.showRelocateConstituents); @@ -147,6 +152,31 @@ export function MenuOperation({ operation, onHide }: MenuOperationProps) { }); } + function handleCreatePhantom() { + onHide(); + notImplemented(); + } + + function handleClone() { + onHide(); + + const layout = getLayout(); + const manager = new LayoutManager(schema, layout); + const newPosition = manager.newClonePosition(operation.nodeID); + if (!newPosition) { + return; + } + + void cloneSchema({ + itemID: schema.id, + data: { + source_operation: operation.id, + layout: layout, + position: newPosition + } + }).then(response => setTimeout(() => setSelected([`o${response.new_operation}`]), PARAMETER.refreshTimeout)); + } + return ( <> } + icon={} onClick={handleOpenSchema} disabled={isProcessing} /> @@ -211,6 +241,26 @@ export function MenuOperation({ operation, onHide }: MenuOperationProps) { /> ) : null} + {isMutable ? ( + } + onClick={handleCreatePhantom} + disabled={isProcessing} + /> + ) : null} + + {isMutable ? ( + } + onClick={handleClone} + disabled={isProcessing || !operation?.result} + /> + ) : null} + }