B: Prevent cyclic dependencies
This commit is contained in:
parent
34893818fa
commit
7496389c31
|
@ -1,4 +1,5 @@
|
|||
''' Serializers for persistent data manipulation. '''
|
||||
from collections import deque
|
||||
from typing import cast
|
||||
|
||||
from django.db.models import F
|
||||
|
@ -62,9 +63,10 @@ class CreateBlockSerializer(serializers.Serializer):
|
|||
|
||||
def validate(self, attrs):
|
||||
oss = cast(LibraryItem, self.context['oss'])
|
||||
if 'parent' in attrs['item_data'] and \
|
||||
attrs['item_data']['parent'] is not None and \
|
||||
attrs['item_data']['parent'].oss_id != oss.pk:
|
||||
parent = attrs['item_data'].get('parent')
|
||||
children_blocks = attrs.get('children_blocks', [])
|
||||
|
||||
if parent is not None and parent.oss_id != oss.pk:
|
||||
raise serializers.ValidationError({
|
||||
'parent': msg.parentNotInOSS()
|
||||
})
|
||||
|
@ -75,11 +77,17 @@ class CreateBlockSerializer(serializers.Serializer):
|
|||
'children_operations': msg.childNotInOSS()
|
||||
})
|
||||
|
||||
for block in attrs['children_blocks']:
|
||||
for block in children_blocks:
|
||||
if block.oss_id != oss.pk:
|
||||
raise serializers.ValidationError({
|
||||
'children_blocks': msg.childNotInOSS()
|
||||
})
|
||||
|
||||
if parent:
|
||||
descendant_ids = _collect_descendants(children_blocks)
|
||||
if parent.pk in descendant_ids:
|
||||
raise serializers.ValidationError({'parent': msg.blockCyclicHierarchy()})
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
|
@ -104,15 +112,15 @@ class UpdateBlockSerializer(serializers.Serializer):
|
|||
'target': msg.blockNotInOSS()
|
||||
})
|
||||
|
||||
if 'parent' in attrs['item_data'] and \
|
||||
attrs['item_data']['parent'] is not None:
|
||||
if attrs['item_data']['parent'].oss_id != oss.pk:
|
||||
parent = attrs['item_data'].get('parent')
|
||||
if parent is not None:
|
||||
if parent.oss_id != oss.pk:
|
||||
raise serializers.ValidationError({
|
||||
'parent': msg.parentNotInOSS()
|
||||
})
|
||||
if attrs['item_data']['parent'] == attrs['target']:
|
||||
if parent == attrs['target']:
|
||||
raise serializers.ValidationError({
|
||||
'parent': msg.blockSelfParent()
|
||||
'parent': msg.blockCyclicHierarchy()
|
||||
})
|
||||
return attrs
|
||||
|
||||
|
@ -142,24 +150,35 @@ class MoveItemsSerializer(serializers.Serializer):
|
|||
def validate(self, attrs):
|
||||
oss = cast(LibraryItem, self.context['oss'])
|
||||
parent_block = cast(Block, attrs['destination'])
|
||||
moved_blocks = attrs.get('blocks', [])
|
||||
moved_operations = attrs.get('operations', [])
|
||||
|
||||
if parent_block is not None and parent_block.oss_id != oss.pk:
|
||||
raise serializers.ValidationError({
|
||||
'destination': msg.blockNotInOSS()
|
||||
})
|
||||
for operation in attrs['operations']:
|
||||
for operation in moved_operations:
|
||||
if operation.oss_id != oss.pk:
|
||||
raise serializers.ValidationError({
|
||||
'operations': msg.operationNotInOSS()
|
||||
})
|
||||
for block in attrs['blocks']:
|
||||
for block in moved_blocks:
|
||||
if parent_block is not None and block.pk == parent_block.pk:
|
||||
raise serializers.ValidationError({
|
||||
'destination': msg.blockSelfParent()
|
||||
'destination': msg.blockCyclicHierarchy()
|
||||
})
|
||||
if block.oss_id != oss.pk:
|
||||
raise serializers.ValidationError({
|
||||
'blocks': msg.blockNotInOSS()
|
||||
})
|
||||
|
||||
if parent_block:
|
||||
ancestor_ids = _collect_ancestors(parent_block)
|
||||
moved_block_ids = {b.pk for b in moved_blocks}
|
||||
if moved_block_ids & ancestor_ids:
|
||||
raise serializers.ValidationError({
|
||||
'destination': msg.blockCyclicHierarchy()
|
||||
})
|
||||
return attrs
|
||||
|
||||
|
||||
|
@ -186,9 +205,8 @@ class CreateOperationSerializer(serializers.Serializer):
|
|||
|
||||
def validate(self, attrs):
|
||||
oss = cast(LibraryItem, self.context['oss'])
|
||||
if 'parent' in attrs['item_data'] and \
|
||||
attrs['item_data']['parent'] is not None and \
|
||||
attrs['item_data']['parent'].oss_id != oss.pk:
|
||||
parent = attrs['item_data'].get('parent')
|
||||
if parent is not None and parent.oss_id != oss.pk:
|
||||
raise serializers.ValidationError({
|
||||
'parent': msg.parentNotInOSS()
|
||||
})
|
||||
|
@ -223,15 +241,14 @@ class UpdateOperationSerializer(serializers.Serializer):
|
|||
|
||||
def validate(self, attrs):
|
||||
oss = cast(LibraryItem, self.context['oss'])
|
||||
parent = attrs['item_data'].get('parent')
|
||||
target = cast(Block, attrs['target'])
|
||||
if target.oss_id != oss.pk:
|
||||
raise serializers.ValidationError({
|
||||
'target': msg.operationNotInOSS()
|
||||
})
|
||||
|
||||
if 'parent' in attrs['item_data'] and \
|
||||
attrs['item_data']['parent'] is not None and \
|
||||
attrs['item_data']['parent'].oss_id != oss.pk:
|
||||
if parent is not None and parent.oss_id != oss.pk:
|
||||
raise serializers.ValidationError({
|
||||
'parent': msg.parentNotInOSS()
|
||||
})
|
||||
|
@ -441,3 +458,29 @@ class RelocateConstituentsSerializer(serializers.Serializer):
|
|||
})
|
||||
|
||||
return attrs
|
||||
|
||||
# ====== Internals =================================================================================
|
||||
|
||||
|
||||
def _collect_descendants(start_blocks: list[Block]) -> set[int]:
|
||||
""" Recursively collect all descendant block IDs from a list of blocks. """
|
||||
visited = set()
|
||||
queue = deque(start_blocks)
|
||||
while queue:
|
||||
block = queue.popleft()
|
||||
if block.pk not in visited:
|
||||
visited.add(block.pk)
|
||||
queue.extend(block.as_child_block.all())
|
||||
return visited
|
||||
|
||||
|
||||
def _collect_ancestors(block: Block) -> set[int]:
|
||||
""" Recursively collect all ancestor block IDs of a block. """
|
||||
ancestors = set()
|
||||
current = block.parent
|
||||
while current:
|
||||
if current.pk in ancestors:
|
||||
break # Prevent infinite loop in malformed data
|
||||
ancestors.add(current.pk)
|
||||
current = current.parent
|
||||
return ancestors
|
||||
|
|
|
@ -168,6 +168,32 @@ class TestOssBlocks(EndpointTester):
|
|||
self.assertEqual(self.block1.parent.pk, new_block['id'])
|
||||
|
||||
|
||||
@decl_endpoint('/api/oss/{item}/create-block', method='post')
|
||||
def test_create_block_cyclic(self):
|
||||
self.populateData()
|
||||
data = {
|
||||
'item_data': {
|
||||
'title': 'Test title',
|
||||
'description': 'Тест кириллицы',
|
||||
'parent': self.block2.pk
|
||||
},
|
||||
'layout': self.layout_data,
|
||||
'position_x': 1337,
|
||||
'position_y': 1337,
|
||||
'width': 0.42,
|
||||
'height': 0.42,
|
||||
'children_operations': [],
|
||||
'children_blocks': [self.block1.pk]
|
||||
}
|
||||
self.executeBadData(data=data, item=self.owned_id)
|
||||
|
||||
data['item_data']['parent'] = self.block1.pk
|
||||
self.executeBadData(data=data)
|
||||
|
||||
data['children_blocks'] = [self.block2.pk]
|
||||
self.executeCreated(data=data)
|
||||
|
||||
|
||||
@decl_endpoint('/api/oss/{item}/delete-block', method='patch')
|
||||
def test_delete_block(self):
|
||||
self.populateData()
|
||||
|
|
|
@ -205,6 +205,24 @@ class TestOssViewset(EndpointTester):
|
|||
self.assertEqual(self.operation2.parent, None)
|
||||
|
||||
|
||||
@decl_endpoint('/api/oss/{item}/move-items', method='patch')
|
||||
def test_move_items_cyclic(self):
|
||||
self.populateData()
|
||||
self.executeBadData(item=self.owned_id)
|
||||
|
||||
block1 = self.owned.create_block(title='1')
|
||||
block2 = self.owned.create_block(title='2', parent=block1)
|
||||
block3 = self.owned.create_block(title='3', parent=block2)
|
||||
|
||||
data = {
|
||||
'layout': self.layout_data,
|
||||
'blocks': [block1.pk],
|
||||
'operations': [],
|
||||
'destination': block3.pk
|
||||
}
|
||||
self.executeBadData(data=data)
|
||||
|
||||
|
||||
@decl_endpoint('/api/oss/relocate-constituents', method='post')
|
||||
def test_relocate_constituents(self):
|
||||
self.populateData()
|
||||
|
|
|
@ -22,7 +22,7 @@ def parentNotInOSS():
|
|||
return 'Родительский блок не принадлежит ОСС'
|
||||
|
||||
|
||||
def blockSelfParent():
|
||||
def blockCyclicHierarchy():
|
||||
return 'Попытка создания циклического вложения'
|
||||
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ import { NoData } from '@/components/view';
|
|||
|
||||
import { labelOssItem } from '../labels';
|
||||
import { type IBlock, type IOperation, type IOperationSchema } from '../models/oss';
|
||||
import { isOperation } from '../models/oss-api';
|
||||
import { getItemID, isOperation } from '../models/oss-api';
|
||||
|
||||
const SELECTION_CLEAR_TIMEOUT = 1000;
|
||||
|
||||
|
@ -21,6 +21,7 @@ interface PickMultiOperationProps extends Styling {
|
|||
onChange: (newValue: number[]) => void;
|
||||
schema: IOperationSchema;
|
||||
rows?: number;
|
||||
exclude?: number[];
|
||||
disallowBlocks?: boolean;
|
||||
}
|
||||
|
||||
|
@ -29,6 +30,7 @@ const columnHelper = createColumnHelper<IOperation | IBlock>();
|
|||
export function PickContents({
|
||||
rows,
|
||||
schema,
|
||||
exclude,
|
||||
value,
|
||||
disallowBlocks,
|
||||
onChange,
|
||||
|
@ -40,38 +42,38 @@ export function PickContents({
|
|||
.filter(item => item !== undefined);
|
||||
const [lastSelected, setLastSelected] = useState<IOperation | IBlock | null>(null);
|
||||
const items = [
|
||||
...schema.operations.filter(item => !value.includes(item.id)),
|
||||
...(disallowBlocks ? [] : schema.blocks.filter(item => !value.includes(-item.id)))
|
||||
...(disallowBlocks ? [] : schema.blocks.filter(item => !value.includes(-item.id) && !exclude?.includes(-item.id))),
|
||||
...schema.operations.filter(item => !value.includes(item.id) && !exclude?.includes(item.id))
|
||||
];
|
||||
|
||||
function handleDelete(operation: number) {
|
||||
onChange(value.filter(item => item !== operation));
|
||||
function handleDelete(target: number) {
|
||||
onChange(value.filter(item => item !== target));
|
||||
}
|
||||
|
||||
function handleSelect(target: IOperation | IBlock | null) {
|
||||
if (target) {
|
||||
setLastSelected(target);
|
||||
onChange([...value, isOperation(target) ? target.id : -target.id]);
|
||||
onChange([...value, getItemID(target)]);
|
||||
setTimeout(() => setLastSelected(null), SELECTION_CLEAR_TIMEOUT);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMoveUp(operation: number) {
|
||||
const index = value.indexOf(operation);
|
||||
function handleMoveUp(target: number) {
|
||||
const index = value.indexOf(target);
|
||||
if (index > 0) {
|
||||
const newSelected = [...value];
|
||||
newSelected[index] = newSelected[index - 1];
|
||||
newSelected[index - 1] = operation;
|
||||
newSelected[index - 1] = target;
|
||||
onChange(newSelected);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMoveDown(operation: number) {
|
||||
const index = value.indexOf(operation);
|
||||
function handleMoveDown(target: number) {
|
||||
const index = value.indexOf(target);
|
||||
if (index < value.length - 1) {
|
||||
const newSelected = [...value];
|
||||
newSelected[index] = newSelected[index + 1];
|
||||
newSelected[index + 1] = operation;
|
||||
newSelected[index + 1] = target;
|
||||
onChange(newSelected);
|
||||
}
|
||||
}
|
||||
|
@ -103,21 +105,21 @@ export function PickContents({
|
|||
noHover
|
||||
className='px-0'
|
||||
icon={<IconRemove size='1rem' className='icon-red' />}
|
||||
onClick={() => handleDelete(props.row.original.id)}
|
||||
onClick={() => handleDelete(getItemID(props.row.original))}
|
||||
/>
|
||||
<MiniButton
|
||||
title='Переместить выше'
|
||||
noHover
|
||||
className='px-0'
|
||||
icon={<IconMoveUp size='1rem' className='icon-primary' />}
|
||||
onClick={() => handleMoveUp(props.row.original.id)}
|
||||
onClick={() => handleMoveUp(getItemID(props.row.original))}
|
||||
/>
|
||||
<MiniButton
|
||||
title='Переместить ниже'
|
||||
noHover
|
||||
className='px-0'
|
||||
icon={<IconMoveDown size='1rem' className='icon-primary' />}
|
||||
onClick={() => handleMoveDown(props.row.original.id)}
|
||||
onClick={() => handleMoveDown(getItemID(props.row.original))}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
@ -131,7 +133,7 @@ export function PickContents({
|
|||
items={items}
|
||||
value={lastSelected}
|
||||
placeholder='Выберите операцию или блок'
|
||||
idFunc={item => String(item.id)}
|
||||
idFunc={item => String(getItemID(item))}
|
||||
labelValueFunc={item => labelOssItem(item)}
|
||||
labelOptionFunc={item => labelOssItem(item)}
|
||||
onChange={handleSelect}
|
||||
|
|
|
@ -18,6 +18,7 @@ export function SelectBlock({ items, placeholder = 'Выберите блок',
|
|||
return (
|
||||
<ComboBox
|
||||
items={items}
|
||||
clearable
|
||||
placeholder={placeholder}
|
||||
idFunc={block => String(block.id)}
|
||||
labelValueFunc={block => block.title}
|
||||
|
|
|
@ -20,7 +20,8 @@ import { TabBlockChildren } from './tab-block-children';
|
|||
|
||||
export interface DlgCreateBlockProps {
|
||||
manager: LayoutManager;
|
||||
initialInputs: number[];
|
||||
initialChildren: number[];
|
||||
initialParent: number | null;
|
||||
defaultX: number;
|
||||
defaultY: number;
|
||||
onCreate?: (newID: number) => void;
|
||||
|
@ -35,7 +36,7 @@ export type TabID = (typeof TabID)[keyof typeof TabID];
|
|||
export function DlgCreateBlock() {
|
||||
const { createBlock } = useCreateBlock();
|
||||
|
||||
const { manager, initialInputs, onCreate, defaultX, defaultY } = useDialogsStore(
|
||||
const { manager, initialChildren, initialParent, onCreate, defaultX, defaultY } = useDialogsStore(
|
||||
state => state.props as DlgCreateBlockProps
|
||||
);
|
||||
|
||||
|
@ -45,19 +46,21 @@ export function DlgCreateBlock() {
|
|||
item_data: {
|
||||
title: '',
|
||||
description: '',
|
||||
parent: null
|
||||
parent: initialParent
|
||||
},
|
||||
position_x: defaultX,
|
||||
position_y: defaultY,
|
||||
width: BLOCK_NODE_MIN_WIDTH,
|
||||
height: BLOCK_NODE_MIN_HEIGHT,
|
||||
children_blocks: initialInputs.filter(id => id < 0).map(id => -id),
|
||||
children_operations: initialInputs.filter(id => id > 0),
|
||||
children_blocks: initialChildren.filter(id => id < 0).map(id => -id),
|
||||
children_operations: initialChildren.filter(id => id > 0),
|
||||
layout: manager.layout
|
||||
},
|
||||
mode: 'onChange'
|
||||
});
|
||||
const title = useWatch({ control: methods.control, name: 'item_data.title' });
|
||||
const children_blocks = useWatch({ control: methods.control, name: 'children_blocks' });
|
||||
const children_operations = useWatch({ control: methods.control, name: 'children_operations' });
|
||||
const [activeTab, setActiveTab] = useState<TabID>(TabID.CARD);
|
||||
const isValid = !!title && !manager.oss.blocks.some(block => block.title === title);
|
||||
|
||||
|
@ -87,7 +90,10 @@ export function DlgCreateBlock() {
|
|||
>
|
||||
<TabList className='z-pop mx-auto -mb-5 flex border divide-x rounded-none bg-secondary'>
|
||||
<TabLabel title='Основные атрибуты блока' label='Карточка' />
|
||||
<TabLabel title='Выбор вложенных узлов' label='Содержимое' />
|
||||
<TabLabel
|
||||
title={`Выбор вложенных узлов: [${children_operations.length + children_blocks.length}]`}
|
||||
label={`Содержимое${children_operations.length + children_blocks.length > 0 ? '*' : ''}`}
|
||||
/>
|
||||
</TabList>
|
||||
|
||||
<FormProvider {...methods}>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { Controller, useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { TextArea, TextInput } from '@/components/input';
|
||||
import { useDialogsStore } from '@/stores/dialogs';
|
||||
|
@ -17,6 +17,11 @@ export function TabBlockCard() {
|
|||
control,
|
||||
formState: { errors }
|
||||
} = useFormContext<ICreateBlockDTO>();
|
||||
const children_blocks = useWatch({ control, name: 'children_blocks' });
|
||||
const all_children = [
|
||||
...children_blocks,
|
||||
...manager.oss.hierarchy.expandAllOutputs(children_blocks.filter(id => id < 0).map(id => -id)).map(id => -id)
|
||||
];
|
||||
|
||||
return (
|
||||
<div className='cc-fade-in cc-column'>
|
||||
|
@ -31,7 +36,7 @@ export function TabBlockCard() {
|
|||
control={control}
|
||||
render={({ field }) => (
|
||||
<SelectParent
|
||||
items={manager.oss.blocks}
|
||||
items={manager.oss.blocks.filter(block => !all_children.includes(block.id))}
|
||||
value={field.value ? manager.oss.blockByID.get(field.value) ?? null : null}
|
||||
placeholder='Блок содержания не выбран'
|
||||
onChange={value => field.onChange(value ? value.id : null)}
|
||||
|
|
|
@ -12,8 +12,12 @@ import { type DlgCreateBlockProps } from './dlg-create-block';
|
|||
export function TabBlockChildren() {
|
||||
const { setValue, control } = useFormContext<ICreateBlockDTO>();
|
||||
const { manager } = useDialogsStore(state => state.props as DlgCreateBlockProps);
|
||||
const parent = useWatch({ control, name: 'item_data.parent' });
|
||||
const children_blocks = useWatch({ control, name: 'children_blocks' });
|
||||
const children_operations = useWatch({ control, name: 'children_operations' });
|
||||
const exclude = parent ? [-parent, ...manager.oss.hierarchy.expandAllInputs([-parent]).filter(id => id < 0)] : [];
|
||||
|
||||
console.log(exclude);
|
||||
|
||||
const value = [...children_blocks.map(id => -id), ...children_operations];
|
||||
|
||||
|
@ -35,6 +39,7 @@ export function TabBlockChildren() {
|
|||
<Label text={`Выбор содержания: [ ${value.length} ]`} />
|
||||
<PickContents
|
||||
schema={manager.oss}
|
||||
exclude={exclude}
|
||||
value={value}
|
||||
onChange={newValue => handleChangeSelected(newValue)}
|
||||
rows={10}
|
||||
|
|
|
@ -33,6 +33,11 @@ export function isOperation(item: IOssItem | null): boolean {
|
|||
return !!item && 'arguments' in item;
|
||||
}
|
||||
|
||||
/** Extract contiguous ID of {@link IOperation} or {@link IBlock}. */
|
||||
export function getItemID(item: IOssItem): number {
|
||||
return isOperation(item) ? item.id : -item.id;
|
||||
}
|
||||
|
||||
/** Sorts library items relevant for the specified {@link IOperationSchema}. */
|
||||
export function sortItemsForOSS(oss: IOperationSchema, items: ILibraryItem[]): ILibraryItem[] {
|
||||
const result = items.filter(item => item.location === oss.location);
|
||||
|
|
|
@ -74,11 +74,13 @@ export function OssFlow() {
|
|||
|
||||
function handleCreateBlock() {
|
||||
const targetPosition = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
|
||||
const parent = extractSingleBlock(selected);
|
||||
showCreateBlock({
|
||||
manager: new LayoutManager(schema, getLayout()),
|
||||
defaultX: targetPosition.x,
|
||||
defaultY: targetPosition.y,
|
||||
initialInputs: selected,
|
||||
initialChildren: parent !== null ? [] : selected,
|
||||
initialParent: parent,
|
||||
onCreate: resetView
|
||||
});
|
||||
}
|
||||
|
|
|
@ -73,10 +73,13 @@ export function useDragging({ hideContextMenu }: DraggingProps) {
|
|||
.filter(id => id < 0)
|
||||
.map(id => schema.blockByID.get(-id))
|
||||
.filter(operation => !!operation);
|
||||
const parents = [...blocks.map(block => block.parent), ...operations.map(operation => operation.parent)].filter(
|
||||
id => !!id
|
||||
const parents = new Set(
|
||||
[...blocks.map(block => block.parent), ...operations.map(operation => operation.parent)].filter(id => !!id)
|
||||
);
|
||||
if ((parents.length !== 1 || parents[0] !== new_parent) && (parents.length !== 0 || new_parent !== null)) {
|
||||
if (
|
||||
(parents.size !== 1 || parents.values().next().value !== new_parent) &&
|
||||
(parents.size !== 0 || new_parent !== null)
|
||||
) {
|
||||
void moveItems({
|
||||
itemID: schema.id,
|
||||
data: {
|
||||
|
|
Loading…
Reference in New Issue
Block a user