B: Prevent cyclic dependencies

This commit is contained in:
Ivan 2025-04-29 21:30:54 +03:00
parent 34893818fa
commit 7496389c31
12 changed files with 163 additions and 47 deletions

View File

@ -1,4 +1,5 @@
''' Serializers for persistent data manipulation. ''' ''' Serializers for persistent data manipulation. '''
from collections import deque
from typing import cast from typing import cast
from django.db.models import F from django.db.models import F
@ -62,9 +63,10 @@ class CreateBlockSerializer(serializers.Serializer):
def validate(self, attrs): def validate(self, attrs):
oss = cast(LibraryItem, self.context['oss']) oss = cast(LibraryItem, self.context['oss'])
if 'parent' in attrs['item_data'] and \ parent = attrs['item_data'].get('parent')
attrs['item_data']['parent'] is not None and \ children_blocks = attrs.get('children_blocks', [])
attrs['item_data']['parent'].oss_id != oss.pk:
if parent is not None and parent.oss_id != oss.pk:
raise serializers.ValidationError({ raise serializers.ValidationError({
'parent': msg.parentNotInOSS() 'parent': msg.parentNotInOSS()
}) })
@ -75,11 +77,17 @@ class CreateBlockSerializer(serializers.Serializer):
'children_operations': msg.childNotInOSS() 'children_operations': msg.childNotInOSS()
}) })
for block in attrs['children_blocks']: for block in children_blocks:
if block.oss_id != oss.pk: if block.oss_id != oss.pk:
raise serializers.ValidationError({ raise serializers.ValidationError({
'children_blocks': msg.childNotInOSS() '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 return attrs
@ -104,15 +112,15 @@ class UpdateBlockSerializer(serializers.Serializer):
'target': msg.blockNotInOSS() 'target': msg.blockNotInOSS()
}) })
if 'parent' in attrs['item_data'] and \ parent = attrs['item_data'].get('parent')
attrs['item_data']['parent'] is not None: if parent is not None:
if attrs['item_data']['parent'].oss_id != oss.pk: if parent.oss_id != oss.pk:
raise serializers.ValidationError({ raise serializers.ValidationError({
'parent': msg.parentNotInOSS() 'parent': msg.parentNotInOSS()
}) })
if attrs['item_data']['parent'] == attrs['target']: if parent == attrs['target']:
raise serializers.ValidationError({ raise serializers.ValidationError({
'parent': msg.blockSelfParent() 'parent': msg.blockCyclicHierarchy()
}) })
return attrs return attrs
@ -142,24 +150,35 @@ class MoveItemsSerializer(serializers.Serializer):
def validate(self, attrs): def validate(self, attrs):
oss = cast(LibraryItem, self.context['oss']) oss = cast(LibraryItem, self.context['oss'])
parent_block = cast(Block, attrs['destination']) 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: if parent_block is not None and parent_block.oss_id != oss.pk:
raise serializers.ValidationError({ raise serializers.ValidationError({
'destination': msg.blockNotInOSS() 'destination': msg.blockNotInOSS()
}) })
for operation in attrs['operations']: for operation in moved_operations:
if operation.oss_id != oss.pk: if operation.oss_id != oss.pk:
raise serializers.ValidationError({ raise serializers.ValidationError({
'operations': msg.operationNotInOSS() '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: if parent_block is not None and block.pk == parent_block.pk:
raise serializers.ValidationError({ raise serializers.ValidationError({
'destination': msg.blockSelfParent() 'destination': msg.blockCyclicHierarchy()
}) })
if block.oss_id != oss.pk: if block.oss_id != oss.pk:
raise serializers.ValidationError({ raise serializers.ValidationError({
'blocks': msg.blockNotInOSS() '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 return attrs
@ -186,9 +205,8 @@ class CreateOperationSerializer(serializers.Serializer):
def validate(self, attrs): def validate(self, attrs):
oss = cast(LibraryItem, self.context['oss']) oss = cast(LibraryItem, self.context['oss'])
if 'parent' in attrs['item_data'] and \ parent = attrs['item_data'].get('parent')
attrs['item_data']['parent'] is not None and \ if parent is not None and parent.oss_id != oss.pk:
attrs['item_data']['parent'].oss_id != oss.pk:
raise serializers.ValidationError({ raise serializers.ValidationError({
'parent': msg.parentNotInOSS() 'parent': msg.parentNotInOSS()
}) })
@ -223,15 +241,14 @@ class UpdateOperationSerializer(serializers.Serializer):
def validate(self, attrs): def validate(self, attrs):
oss = cast(LibraryItem, self.context['oss']) oss = cast(LibraryItem, self.context['oss'])
parent = attrs['item_data'].get('parent')
target = cast(Block, attrs['target']) target = cast(Block, attrs['target'])
if target.oss_id != oss.pk: if target.oss_id != oss.pk:
raise serializers.ValidationError({ raise serializers.ValidationError({
'target': msg.operationNotInOSS() 'target': msg.operationNotInOSS()
}) })
if 'parent' in attrs['item_data'] and \ if parent is not None and parent.oss_id != oss.pk:
attrs['item_data']['parent'] is not None and \
attrs['item_data']['parent'].oss_id != oss.pk:
raise serializers.ValidationError({ raise serializers.ValidationError({
'parent': msg.parentNotInOSS() 'parent': msg.parentNotInOSS()
}) })
@ -441,3 +458,29 @@ class RelocateConstituentsSerializer(serializers.Serializer):
}) })
return attrs 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']) 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') @decl_endpoint('/api/oss/{item}/delete-block', method='patch')
def test_delete_block(self): def test_delete_block(self):
self.populateData() self.populateData()

View File

@ -205,6 +205,24 @@ class TestOssViewset(EndpointTester):
self.assertEqual(self.operation2.parent, None) 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') @decl_endpoint('/api/oss/relocate-constituents', method='post')
def test_relocate_constituents(self): def test_relocate_constituents(self):
self.populateData() self.populateData()

View File

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

View File

@ -12,7 +12,7 @@ import { NoData } from '@/components/view';
import { labelOssItem } from '../labels'; import { labelOssItem } from '../labels';
import { type IBlock, type IOperation, type IOperationSchema } from '../models/oss'; 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; const SELECTION_CLEAR_TIMEOUT = 1000;
@ -21,6 +21,7 @@ interface PickMultiOperationProps extends Styling {
onChange: (newValue: number[]) => void; onChange: (newValue: number[]) => void;
schema: IOperationSchema; schema: IOperationSchema;
rows?: number; rows?: number;
exclude?: number[];
disallowBlocks?: boolean; disallowBlocks?: boolean;
} }
@ -29,6 +30,7 @@ const columnHelper = createColumnHelper<IOperation | IBlock>();
export function PickContents({ export function PickContents({
rows, rows,
schema, schema,
exclude,
value, value,
disallowBlocks, disallowBlocks,
onChange, onChange,
@ -40,38 +42,38 @@ export function PickContents({
.filter(item => item !== undefined); .filter(item => item !== undefined);
const [lastSelected, setLastSelected] = useState<IOperation | IBlock | null>(null); const [lastSelected, setLastSelected] = useState<IOperation | IBlock | null>(null);
const items = [ const items = [
...schema.operations.filter(item => !value.includes(item.id)), ...(disallowBlocks ? [] : schema.blocks.filter(item => !value.includes(-item.id) && !exclude?.includes(-item.id))),
...(disallowBlocks ? [] : schema.blocks.filter(item => !value.includes(-item.id))) ...schema.operations.filter(item => !value.includes(item.id) && !exclude?.includes(item.id))
]; ];
function handleDelete(operation: number) { function handleDelete(target: number) {
onChange(value.filter(item => item !== operation)); onChange(value.filter(item => item !== target));
} }
function handleSelect(target: IOperation | IBlock | null) { function handleSelect(target: IOperation | IBlock | null) {
if (target) { if (target) {
setLastSelected(target); setLastSelected(target);
onChange([...value, isOperation(target) ? target.id : -target.id]); onChange([...value, getItemID(target)]);
setTimeout(() => setLastSelected(null), SELECTION_CLEAR_TIMEOUT); setTimeout(() => setLastSelected(null), SELECTION_CLEAR_TIMEOUT);
} }
} }
function handleMoveUp(operation: number) { function handleMoveUp(target: number) {
const index = value.indexOf(operation); const index = value.indexOf(target);
if (index > 0) { if (index > 0) {
const newSelected = [...value]; const newSelected = [...value];
newSelected[index] = newSelected[index - 1]; newSelected[index] = newSelected[index - 1];
newSelected[index - 1] = operation; newSelected[index - 1] = target;
onChange(newSelected); onChange(newSelected);
} }
} }
function handleMoveDown(operation: number) { function handleMoveDown(target: number) {
const index = value.indexOf(operation); const index = value.indexOf(target);
if (index < value.length - 1) { if (index < value.length - 1) {
const newSelected = [...value]; const newSelected = [...value];
newSelected[index] = newSelected[index + 1]; newSelected[index] = newSelected[index + 1];
newSelected[index + 1] = operation; newSelected[index + 1] = target;
onChange(newSelected); onChange(newSelected);
} }
} }
@ -103,21 +105,21 @@ export function PickContents({
noHover noHover
className='px-0' className='px-0'
icon={<IconRemove size='1rem' className='icon-red' />} icon={<IconRemove size='1rem' className='icon-red' />}
onClick={() => handleDelete(props.row.original.id)} onClick={() => handleDelete(getItemID(props.row.original))}
/> />
<MiniButton <MiniButton
title='Переместить выше' title='Переместить выше'
noHover noHover
className='px-0' className='px-0'
icon={<IconMoveUp size='1rem' className='icon-primary' />} icon={<IconMoveUp size='1rem' className='icon-primary' />}
onClick={() => handleMoveUp(props.row.original.id)} onClick={() => handleMoveUp(getItemID(props.row.original))}
/> />
<MiniButton <MiniButton
title='Переместить ниже' title='Переместить ниже'
noHover noHover
className='px-0' className='px-0'
icon={<IconMoveDown size='1rem' className='icon-primary' />} icon={<IconMoveDown size='1rem' className='icon-primary' />}
onClick={() => handleMoveDown(props.row.original.id)} onClick={() => handleMoveDown(getItemID(props.row.original))}
/> />
</div> </div>
) )
@ -131,7 +133,7 @@ export function PickContents({
items={items} items={items}
value={lastSelected} value={lastSelected}
placeholder='Выберите операцию или блок' placeholder='Выберите операцию или блок'
idFunc={item => String(item.id)} idFunc={item => String(getItemID(item))}
labelValueFunc={item => labelOssItem(item)} labelValueFunc={item => labelOssItem(item)}
labelOptionFunc={item => labelOssItem(item)} labelOptionFunc={item => labelOssItem(item)}
onChange={handleSelect} onChange={handleSelect}

View File

@ -18,6 +18,7 @@ export function SelectBlock({ items, placeholder = 'Выберите блок',
return ( return (
<ComboBox <ComboBox
items={items} items={items}
clearable
placeholder={placeholder} placeholder={placeholder}
idFunc={block => String(block.id)} idFunc={block => String(block.id)}
labelValueFunc={block => block.title} labelValueFunc={block => block.title}

View File

@ -20,7 +20,8 @@ import { TabBlockChildren } from './tab-block-children';
export interface DlgCreateBlockProps { export interface DlgCreateBlockProps {
manager: LayoutManager; manager: LayoutManager;
initialInputs: number[]; initialChildren: number[];
initialParent: number | null;
defaultX: number; defaultX: number;
defaultY: number; defaultY: number;
onCreate?: (newID: number) => void; onCreate?: (newID: number) => void;
@ -35,7 +36,7 @@ export type TabID = (typeof TabID)[keyof typeof TabID];
export function DlgCreateBlock() { export function DlgCreateBlock() {
const { createBlock } = useCreateBlock(); const { createBlock } = useCreateBlock();
const { manager, initialInputs, onCreate, defaultX, defaultY } = useDialogsStore( const { manager, initialChildren, initialParent, onCreate, defaultX, defaultY } = useDialogsStore(
state => state.props as DlgCreateBlockProps state => state.props as DlgCreateBlockProps
); );
@ -45,19 +46,21 @@ export function DlgCreateBlock() {
item_data: { item_data: {
title: '', title: '',
description: '', description: '',
parent: null parent: initialParent
}, },
position_x: defaultX, position_x: defaultX,
position_y: defaultY, position_y: defaultY,
width: BLOCK_NODE_MIN_WIDTH, width: BLOCK_NODE_MIN_WIDTH,
height: BLOCK_NODE_MIN_HEIGHT, height: BLOCK_NODE_MIN_HEIGHT,
children_blocks: initialInputs.filter(id => id < 0).map(id => -id), children_blocks: initialChildren.filter(id => id < 0).map(id => -id),
children_operations: initialInputs.filter(id => id > 0), children_operations: initialChildren.filter(id => id > 0),
layout: manager.layout layout: manager.layout
}, },
mode: 'onChange' mode: 'onChange'
}); });
const title = useWatch({ control: methods.control, name: 'item_data.title' }); 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 [activeTab, setActiveTab] = useState<TabID>(TabID.CARD);
const isValid = !!title && !manager.oss.blocks.some(block => block.title === title); 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'> <TabList className='z-pop mx-auto -mb-5 flex border divide-x rounded-none bg-secondary'>
<TabLabel title='Основные атрибуты блока' label='Карточка' /> <TabLabel title='Основные атрибуты блока' label='Карточка' />
<TabLabel title='Выбор вложенных узлов' label='Содержимое' /> <TabLabel
title={`Выбор вложенных узлов: [${children_operations.length + children_blocks.length}]`}
label={`Содержимое${children_operations.length + children_blocks.length > 0 ? '*' : ''}`}
/>
</TabList> </TabList>
<FormProvider {...methods}> <FormProvider {...methods}>

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { Controller, useFormContext } from 'react-hook-form'; import { Controller, useFormContext, useWatch } from 'react-hook-form';
import { TextArea, TextInput } from '@/components/input'; import { TextArea, TextInput } from '@/components/input';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
@ -17,6 +17,11 @@ export function TabBlockCard() {
control, control,
formState: { errors } formState: { errors }
} = useFormContext<ICreateBlockDTO>(); } = 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 ( return (
<div className='cc-fade-in cc-column'> <div className='cc-fade-in cc-column'>
@ -31,7 +36,7 @@ export function TabBlockCard() {
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<SelectParent <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} value={field.value ? manager.oss.blockByID.get(field.value) ?? null : null}
placeholder='Блок содержания не выбран' placeholder='Блок содержания не выбран'
onChange={value => field.onChange(value ? value.id : null)} onChange={value => field.onChange(value ? value.id : null)}

View File

@ -12,8 +12,12 @@ import { type DlgCreateBlockProps } from './dlg-create-block';
export function TabBlockChildren() { export function TabBlockChildren() {
const { setValue, control } = useFormContext<ICreateBlockDTO>(); const { setValue, control } = useFormContext<ICreateBlockDTO>();
const { manager } = useDialogsStore(state => state.props as DlgCreateBlockProps); 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_blocks = useWatch({ control, name: 'children_blocks' });
const children_operations = useWatch({ control, name: 'children_operations' }); 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]; const value = [...children_blocks.map(id => -id), ...children_operations];
@ -35,6 +39,7 @@ export function TabBlockChildren() {
<Label text={`Выбор содержания: [ ${value.length} ]`} /> <Label text={`Выбор содержания: [ ${value.length} ]`} />
<PickContents <PickContents
schema={manager.oss} schema={manager.oss}
exclude={exclude}
value={value} value={value}
onChange={newValue => handleChangeSelected(newValue)} onChange={newValue => handleChangeSelected(newValue)}
rows={10} rows={10}

View File

@ -33,6 +33,11 @@ export function isOperation(item: IOssItem | null): boolean {
return !!item && 'arguments' in item; 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}. */ /** Sorts library items relevant for the specified {@link IOperationSchema}. */
export function sortItemsForOSS(oss: IOperationSchema, items: ILibraryItem[]): ILibraryItem[] { export function sortItemsForOSS(oss: IOperationSchema, items: ILibraryItem[]): ILibraryItem[] {
const result = items.filter(item => item.location === oss.location); const result = items.filter(item => item.location === oss.location);

View File

@ -74,11 +74,13 @@ export function OssFlow() {
function handleCreateBlock() { function handleCreateBlock() {
const targetPosition = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); const targetPosition = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
const parent = extractSingleBlock(selected);
showCreateBlock({ showCreateBlock({
manager: new LayoutManager(schema, getLayout()), manager: new LayoutManager(schema, getLayout()),
defaultX: targetPosition.x, defaultX: targetPosition.x,
defaultY: targetPosition.y, defaultY: targetPosition.y,
initialInputs: selected, initialChildren: parent !== null ? [] : selected,
initialParent: parent,
onCreate: resetView onCreate: resetView
}); });
} }

View File

@ -73,10 +73,13 @@ export function useDragging({ hideContextMenu }: DraggingProps) {
.filter(id => id < 0) .filter(id => id < 0)
.map(id => schema.blockByID.get(-id)) .map(id => schema.blockByID.get(-id))
.filter(operation => !!operation); .filter(operation => !!operation);
const parents = [...blocks.map(block => block.parent), ...operations.map(operation => operation.parent)].filter( const parents = new Set(
id => !!id [...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({ void moveItems({
itemID: schema.id, itemID: schema.id,
data: { data: {