mirror of
https://github.com/IRBorisov/ConceptPortal.git
synced 2025-08-13 12:20:36 +03:00
F: Add clone-schema to OSS functions
This commit is contained in:
parent
1af6f87824
commit
fc05d53d31
|
@ -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():
|
||||
|
|
|
@ -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. '''
|
||||
|
|
|
@ -4,6 +4,7 @@ from .basics import LayoutSerializer, SubstitutionExSerializer
|
|||
from .data_access import (
|
||||
ArgumentSerializer,
|
||||
BlockSerializer,
|
||||
CloneSchemaSerializer,
|
||||
CreateBlockSerializer,
|
||||
CreateSchemaSerializer,
|
||||
CreateSynthesisSerializer,
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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}'
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
};
|
||||
};
|
|
@ -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);
|
||||
|
|
|
@ -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' />}
|
||||
|
|
Loading…
Reference in New Issue
Block a user