From 7496389c31ca267e8cdf85f154dabe342bc74e82 Mon Sep 17 00:00:00 2001 From: Ivan <8611739+IRBorisov@users.noreply.github.com> Date: Tue, 29 Apr 2025 21:30:54 +0300 Subject: [PATCH] B: Prevent cyclic dependencies --- .../apps/oss/serializers/data_access.py | 79 ++++++++++++++----- .../apps/oss/tests/s_views/t_blocks.py | 26 ++++++ .../backend/apps/oss/tests/s_views/t_oss.py | 18 +++++ rsconcept/backend/shared/messages.py | 2 +- .../features/oss/components/pick-contents.tsx | 34 ++++---- .../features/oss/components/select-block.tsx | 1 + .../dlg-create-block/dlg-create-block.tsx | 18 +++-- .../dlg-create-block/tab-block-card.tsx | 9 ++- .../dlg-create-block/tab-block-children.tsx | 5 ++ .../src/features/oss/models/oss-api.ts | 5 ++ .../oss-page/editor-oss-graph/oss-flow.tsx | 4 +- .../editor-oss-graph/use-dragging.tsx | 9 ++- 12 files changed, 163 insertions(+), 47 deletions(-) diff --git a/rsconcept/backend/apps/oss/serializers/data_access.py b/rsconcept/backend/apps/oss/serializers/data_access.py index a95c51a5..98329c7c 100644 --- a/rsconcept/backend/apps/oss/serializers/data_access.py +++ b/rsconcept/backend/apps/oss/serializers/data_access.py @@ -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 diff --git a/rsconcept/backend/apps/oss/tests/s_views/t_blocks.py b/rsconcept/backend/apps/oss/tests/s_views/t_blocks.py index 6a4229c5..3bc190c6 100644 --- a/rsconcept/backend/apps/oss/tests/s_views/t_blocks.py +++ b/rsconcept/backend/apps/oss/tests/s_views/t_blocks.py @@ -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() diff --git a/rsconcept/backend/apps/oss/tests/s_views/t_oss.py b/rsconcept/backend/apps/oss/tests/s_views/t_oss.py index 6fbaf791..8e55f271 100644 --- a/rsconcept/backend/apps/oss/tests/s_views/t_oss.py +++ b/rsconcept/backend/apps/oss/tests/s_views/t_oss.py @@ -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() diff --git a/rsconcept/backend/shared/messages.py b/rsconcept/backend/shared/messages.py index 9bf67c53..a317348e 100644 --- a/rsconcept/backend/shared/messages.py +++ b/rsconcept/backend/shared/messages.py @@ -22,7 +22,7 @@ def parentNotInOSS(): return 'Родительский блок не принадлежит ОСС' -def blockSelfParent(): +def blockCyclicHierarchy(): return 'Попытка создания циклического вложения' diff --git a/rsconcept/frontend/src/features/oss/components/pick-contents.tsx b/rsconcept/frontend/src/features/oss/components/pick-contents.tsx index 4c5f3a74..b9b0b56c 100644 --- a/rsconcept/frontend/src/features/oss/components/pick-contents.tsx +++ b/rsconcept/frontend/src/features/oss/components/pick-contents.tsx @@ -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(); export function PickContents({ rows, schema, + exclude, value, disallowBlocks, onChange, @@ -40,38 +42,38 @@ export function PickContents({ .filter(item => item !== undefined); const [lastSelected, setLastSelected] = useState(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={} - onClick={() => handleDelete(props.row.original.id)} + onClick={() => handleDelete(getItemID(props.row.original))} /> } - onClick={() => handleMoveUp(props.row.original.id)} + onClick={() => handleMoveUp(getItemID(props.row.original))} /> } - onClick={() => handleMoveDown(props.row.original.id)} + onClick={() => handleMoveDown(getItemID(props.row.original))} /> ) @@ -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} diff --git a/rsconcept/frontend/src/features/oss/components/select-block.tsx b/rsconcept/frontend/src/features/oss/components/select-block.tsx index a858010e..5462effd 100644 --- a/rsconcept/frontend/src/features/oss/components/select-block.tsx +++ b/rsconcept/frontend/src/features/oss/components/select-block.tsx @@ -18,6 +18,7 @@ export function SelectBlock({ items, placeholder = 'Выберите блок', return ( String(block.id)} labelValueFunc={block => block.title} diff --git a/rsconcept/frontend/src/features/oss/dialogs/dlg-create-block/dlg-create-block.tsx b/rsconcept/frontend/src/features/oss/dialogs/dlg-create-block/dlg-create-block.tsx index c0ff781b..7ac0aa39 100644 --- a/rsconcept/frontend/src/features/oss/dialogs/dlg-create-block/dlg-create-block.tsx +++ b/rsconcept/frontend/src/features/oss/dialogs/dlg-create-block/dlg-create-block.tsx @@ -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.CARD); const isValid = !!title && !manager.oss.blocks.some(block => block.title === title); @@ -87,7 +90,10 @@ export function DlgCreateBlock() { > - + 0 ? '*' : ''}`} + /> diff --git a/rsconcept/frontend/src/features/oss/dialogs/dlg-create-block/tab-block-card.tsx b/rsconcept/frontend/src/features/oss/dialogs/dlg-create-block/tab-block-card.tsx index baac56ed..b2139c7b 100644 --- a/rsconcept/frontend/src/features/oss/dialogs/dlg-create-block/tab-block-card.tsx +++ b/rsconcept/frontend/src/features/oss/dialogs/dlg-create-block/tab-block-card.tsx @@ -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(); + 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 (
@@ -31,7 +36,7 @@ export function TabBlockCard() { control={control} render={({ field }) => ( !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)} diff --git a/rsconcept/frontend/src/features/oss/dialogs/dlg-create-block/tab-block-children.tsx b/rsconcept/frontend/src/features/oss/dialogs/dlg-create-block/tab-block-children.tsx index a0ffe3fb..def07853 100644 --- a/rsconcept/frontend/src/features/oss/dialogs/dlg-create-block/tab-block-children.tsx +++ b/rsconcept/frontend/src/features/oss/dialogs/dlg-create-block/tab-block-children.tsx @@ -12,8 +12,12 @@ import { type DlgCreateBlockProps } from './dlg-create-block'; export function TabBlockChildren() { const { setValue, control } = useFormContext(); 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() {