B: Prevent cyclic dependencies

This commit is contained in:
Ivan 2025-04-29 21:41:21 +03:00
parent e6a5b49b7a
commit 68843760a7
12 changed files with 163 additions and 47 deletions

View File

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

View File

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

View File

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

View File

@ -22,7 +22,7 @@ def parentNotInOSS():
return 'Родительский блок не принадлежит ОСС'
def blockSelfParent():
def blockCyclicHierarchy():
return 'Попытка создания циклического вложения'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: {