F: Add clone-schema to OSS functions
Some checks failed
Backend CI / build (3.12) (push) Waiting to run
Backend CI / notify-failure (push) Blocked by required conditions
Frontend CI / build (22.x) (push) Has been cancelled
Frontend CI / notify-failure (push) Has been cancelled

This commit is contained in:
Ivan 2025-07-30 21:50:14 +03:00
parent 1af6f87824
commit fc05d53d31
13 changed files with 264 additions and 14 deletions

View File

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

View File

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

View File

@ -4,6 +4,7 @@ from .basics import LayoutSerializer, SubstitutionExSerializer
from .data_access import (
ArgumentSerializer,
BlockSerializer,
CloneSchemaSerializer,
CreateBlockSerializer,
CreateSchemaSerializer,
CreateSynthesisSerializer,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<ICloneSchemaDTO, IOperationCreatedResponse>({
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<ICreateSynthesisDTO, IOperationCreatedResponse>({
schema: schemaOperationCreatedResponse,

View File

@ -50,6 +50,7 @@ export type IMoveItemsDTO = z.infer<typeof schemaMoveItems>;
export type ICreateSchemaDTO = z.infer<typeof schemaCreateSchema>;
export type ICreateSynthesisDTO = z.infer<typeof schemaCreateSynthesis>;
export type IImportSchemaDTO = z.infer<typeof schemaImportSchema>;
export type ICloneSchemaDTO = z.infer<typeof schemaCloneSchema>;
/** Represents data response when creating {@link IOperation}. */
export type IOperationCreatedResponse = z.infer<typeof schemaOperationCreatedResponse>;
@ -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,

View File

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

View File

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

View File

@ -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 (
<>
<DropdownButton
@ -162,7 +192,7 @@ export function MenuOperation({ operation, onHide }: MenuOperationProps) {
text='Открыть схему'
titleHtml={prepareTooltip('Открыть привязанную КС', 'Двойной клик')}
aria-label='Открыть привязанную КС'
icon={<IconRSForm size='1rem' className='icon-green' />}
icon={<IconRSForm size='1rem' className='icon-primary' />}
onClick={handleOpenSchema}
disabled={isProcessing}
/>
@ -211,6 +241,26 @@ export function MenuOperation({ operation, onHide }: MenuOperationProps) {
/>
) : null}
{isMutable ? (
<DropdownButton
text='Создать ссылку'
title='Создать ссылку на результат операции'
icon={<IconPhantom size='1rem' className='icon-green' />}
onClick={handleCreatePhantom}
disabled={isProcessing}
/>
) : null}
{isMutable ? (
<DropdownButton
text='Клонировать'
title='Создать и загрузить копию концептуальной схемы'
icon={<IconClone size='1rem' className='icon-green' />}
onClick={handleClone}
disabled={isProcessing || !operation?.result}
/>
) : null}
<DropdownButton
text='Удалить операцию'
icon={<IconDestroy size='1rem' className='icon-red' />}