diff --git a/rsconcept/backend/apps/oss/tests/s_propagation/t_constituents.py b/rsconcept/backend/apps/oss/tests/s_propagation/t_constituents.py index 84732cf0..ae7ed1d5 100644 --- a/rsconcept/backend/apps/oss/tests/s_propagation/t_constituents.py +++ b/rsconcept/backend/apps/oss/tests/s_propagation/t_constituents.py @@ -56,6 +56,7 @@ class TestChangeConstituents(EndpointTester): self.operation3.refresh_from_db() self.ks3 = RSForm(self.operation3.result) self.assertEqual(self.ks3.constituentsQ().count(), 4) + self.ks3X1 = Constituenta.objects.get(as_child__parent_id=self.ks1X1.pk) self.layout_data = [ {'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, @@ -142,6 +143,29 @@ class TestChangeConstituents(EndpointTester): self.assertEqual(inherited_cst.definition_raw, r'@{X2|sing,datv}') + @decl_endpoint('/api/rsforms/{item}/update-cst', method='patch') + def test_update_constituenta_inherited(self): + data = { + 'target': self.ks3X1.pk, + 'item_data': { + 'definition_formal': r'123', + } + } + self.executeBadData(data, item=self.ks3.model.pk) + + data = { + 'target': self.ks3X1.pk, + 'item_data': { + 'term_raw': r'42', + 'convention': r'1337', + } + } + self.executeOK(data, item=self.ks3.model.pk) + self.ks3X1.refresh_from_db() + self.assertEqual(self.ks3X1.term_raw, data['item_data']['term_raw']) + self.assertEqual(self.ks3X1.convention, data['item_data']['convention']) + + @decl_endpoint('/api/rsforms/{item}/delete-multiple-cst', method='patch') def test_delete_constituenta(self): data = {'items': [self.ks2X1.pk]} diff --git a/rsconcept/backend/apps/rsform/serializers/data_access.py b/rsconcept/backend/apps/rsform/serializers/data_access.py index 78f534c9..f1d52494 100644 --- a/rsconcept/backend/apps/rsform/serializers/data_access.py +++ b/rsconcept/backend/apps/rsform/serializers/data_access.py @@ -116,6 +116,11 @@ class CstUpdateSerializer(StrictSerializer): raise serializers.ValidationError({ 'alias': msg.aliasTaken(new_alias) }) + if 'definition_formal' in attrs['item_data']: + if Inheritance.objects.filter(child=cst).exists(): + raise serializers.ValidationError({ + 'definition_formal': msg.changeInheritedDefinition() + }) return attrs diff --git a/rsconcept/backend/apps/rsform/views/rsforms.py b/rsconcept/backend/apps/rsform/views/rsforms.py index 920c399f..4fe2d638 100644 --- a/rsconcept/backend/apps/rsform/views/rsforms.py +++ b/rsconcept/backend/apps/rsform/views/rsforms.py @@ -148,7 +148,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr ) @extend_schema( - summary='update crucial attributes of a given list of constituents', + summary='update crucial attribute of a given list of constituents', tags=['RSForm'], request=s.CrucialUpdateSerializer, responses={ @@ -160,7 +160,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr ) @action(detail=True, methods=['patch'], url_path='update-crucial') def update_crucial(self, request: Request, pk) -> HttpResponse: - ''' Update crucial attributes of a given list of constituents. ''' + ''' Update crucial attribute of a given list of constituents. ''' item = self._get_item() serializer = s.CrucialUpdateSerializer(data=request.data, partial=True, context={'schema': item}) serializer.is_valid(raise_exception=True) diff --git a/rsconcept/backend/shared/messages.py b/rsconcept/backend/shared/messages.py index 94236151..91440752 100644 --- a/rsconcept/backend/shared/messages.py +++ b/rsconcept/backend/shared/messages.py @@ -22,6 +22,10 @@ def constituentaNotInRSform(title: str): return f'Конституента не принадлежит схеме: {title}' +def changeInheritedDefinition(): + return 'Нельзя изменить определение наследника' + + def constituentaNotFromOperation(): return 'Конституента не соответствую аргументам операции' diff --git a/rsconcept/frontend/src/components/flow/use-continous-panning.tsx b/rsconcept/frontend/src/components/flow/use-continuous-panning.tsx similarity index 100% rename from rsconcept/frontend/src/components/flow/use-continous-panning.tsx rename to rsconcept/frontend/src/components/flow/use-continuous-panning.tsx diff --git a/rsconcept/frontend/src/features/rsform/backend/rsform-loader.ts b/rsconcept/frontend/src/features/rsform/backend/rsform-loader.ts index a14f5c75..0cf2c3df 100644 --- a/rsconcept/frontend/src/features/rsform/backend/rsform-loader.ts +++ b/rsconcept/frontend/src/features/rsform/backend/rsform-loader.ts @@ -88,7 +88,7 @@ export class RSFormLoader { cst.spawn = []; cst.attributes = []; cst.spawn_alias = []; - cst.parent_schema = schemaByCst.get(cst.id); + cst.parent_schema = schemaByCst.get(cst.id) ?? null; cst.parent_schema_index = cst.parent_schema ? parents.indexOf(cst.parent_schema) + 1 : 0; cst.is_inherited = inherit_children.has(cst.id); cst.has_inherited_children = inherit_parents.has(cst.id); diff --git a/rsconcept/frontend/src/features/rsform/components/term-graph/schemas-guide.tsx b/rsconcept/frontend/src/features/rsform/components/term-graph/schemas-guide.tsx index 5b92b38b..4b9f1ea2 100644 --- a/rsconcept/frontend/src/features/rsform/components/term-graph/schemas-guide.tsx +++ b/rsconcept/frontend/src/features/rsform/components/term-graph/schemas-guide.tsx @@ -19,7 +19,7 @@ export function SchemasGuide({ schema }: SchemasGuideProps) { const aliases: string[] = []; const indexes: number[] = []; schema.items.forEach(cst => { - if (cst.parent_schema && !processed.has(cst.parent_schema)) { + if (cst.parent_schema !== null && !processed.has(cst.parent_schema)) { const item = libraryItems.find(item => item.id === cst.parent_schema); if (item) { aliases.push(item.alias); diff --git a/rsconcept/frontend/src/features/rsform/models/rsform.ts b/rsconcept/frontend/src/features/rsform/models/rsform.ts index a12907fc..16e4ac37 100644 --- a/rsconcept/frontend/src/features/rsform/models/rsform.ts +++ b/rsconcept/frontend/src/features/rsform/models/rsform.ts @@ -86,7 +86,7 @@ export interface IConstituenta { */ parent_schema_index: number; /** {@link LibraryItem} that contains parent of this inherited {@link IConstituenta}. */ - parent_schema?: number; + parent_schema: number | null; /** Indicates if this {@link IConstituenta} is inherited. */ is_inherited: boolean; /** Indicates if this {@link IConstituenta} has children that are inherited. */ diff --git a/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-term-graph/tg-flow.tsx b/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-term-graph/tg-flow.tsx index e040b743..ecbfcae9 100644 --- a/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-term-graph/tg-flow.tsx +++ b/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-term-graph/tg-flow.tsx @@ -15,7 +15,7 @@ import { import clsx from 'clsx'; import { DiagramFlow, useReactFlow } from '@/components/flow/diagram-flow'; -import { useContinuousPan } from '@/components/flow/use-continous-panning'; +import { useContinuousPan } from '@/components/flow/use-continuous-panning'; import { useWindowSize } from '@/hooks/use-window-size'; import { useFitHeight, useMainHeight } from '@/stores/app-layout'; import { PARAMETER } from '@/utils/constants'; @@ -255,20 +255,26 @@ export function TGFlow() { const sourceID = Number(connection.source); const targetID = Number(connection.target); - if ( - (connectionType === TGEdgeType.attribution && schema.attribution_graph.hasEdge(sourceID, targetID)) || - (connectionType === TGEdgeType.definition && schema.graph.hasEdge(sourceID, targetID)) - ) { - toast.info(errorMsg.connectionExists); - return; + const sourceCst = schema.cstByID.get(sourceID); + const targetCst = schema.cstByID.get(targetID); + if (!targetCst || !sourceCst) { + throw new Error('Constituents not found'); } if (connectionType === TGEdgeType.definition) { - const sourceCst = schema.cstByID.get(sourceID); - const targetCst = schema.cstByID.get(targetID); - if (!targetCst || !sourceCst) { - throw new Error('Constituents not found'); + if (targetCst.is_inherited) { + toast.error(errorMsg.changeInheritedDefinition); + return; } + if (schema.graph.hasEdge(sourceID, targetID)) { + toast.error(errorMsg.connectionExists); + return; + } + if (schema.graph.isReachable(targetID, sourceID)) { + toast.error(errorMsg.cyclingEdge); + return; + } + const newExpressions = addAliasReference(targetCst.definition_formal, sourceCst.alias); void updateConstituenta({ itemID: schema.id, @@ -279,8 +285,20 @@ export function TGFlow() { } } }); - return; } else { + if (schema.attribution_graph.hasEdge(sourceID, targetID)) { + toast.error(errorMsg.connectionExists); + return; + } + if (schema.attribution_graph.isReachable(targetID, sourceID)) { + toast.error(errorMsg.cyclingEdge); + return; + } + if (targetCst.parent_schema !== null && targetCst.parent_schema === sourceCst.parent_schema) { + toast.error(errorMsg.addInheritedEdge); + return; + } + void createAttribution({ itemID: schema.id, data: { diff --git a/rsconcept/frontend/src/features/rsform/pages/rsform-page/rsedit-state.tsx b/rsconcept/frontend/src/features/rsform/pages/rsform-page/rsedit-state.tsx index bfb1ea95..fdfc2d1f 100644 --- a/rsconcept/frontend/src/features/rsform/pages/rsform-page/rsedit-state.tsx +++ b/rsconcept/frontend/src/features/rsform/pages/rsform-page/rsedit-state.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; +import { toast } from 'react-toastify'; import { urls, useConceptNavigation } from '@/app'; import { useAIStore } from '@/features/ai/stores/ai-context'; @@ -14,7 +15,7 @@ import { useDialogsStore } from '@/stores/dialogs'; import { useModificationStore } from '@/stores/modification'; import { usePreferencesStore } from '@/stores/preferences'; import { PARAMETER, prefixes } from '@/utils/constants'; -import { promptText } from '@/utils/labels'; +import { errorMsg, promptText } from '@/utils/labels'; import { type RO } from '@/utils/meta'; import { promptUnsaved } from '@/utils/utils'; @@ -302,7 +303,16 @@ export const RSEditState = ({ const ids = selectedEdges[0].split('-'); const sourceID = Number(ids[0]); const targetID = Number(ids[1]); + const sourceCst = schema.cstByID.get(sourceID); + const targetCst = schema.cstByID.get(targetID); + if (!targetCst || !sourceCst) { + throw new Error('Constituents not found'); + } if (schema.attribution_graph.hasEdge(sourceID, targetID)) { + if (targetCst.parent_schema !== null && targetCst.parent_schema === sourceCst.parent_schema) { + toast.error(errorMsg.deleteInheritedEdge); + return; + } void deleteAttribution({ itemID: schema.id, data: { @@ -311,10 +321,9 @@ export const RSEditState = ({ } }); } else if (schema.graph.hasEdge(sourceID, targetID)) { - const sourceCst = schema.cstByID.get(sourceID); - const targetCst = schema.cstByID.get(targetID); - if (!targetCst || !sourceCst) { - throw new Error('Constituents not found'); + if (targetCst.is_inherited) { + toast.error(errorMsg.changeInheritedDefinition); + return; } const newExpressions = removeAliasReference(targetCst.definition_formal, sourceCst.alias); void updateConstituenta({ diff --git a/rsconcept/frontend/src/models/graph.ts b/rsconcept/frontend/src/models/graph.ts index c53c4058..bea552e4 100644 --- a/rsconcept/frontend/src/models/graph.ts +++ b/rsconcept/frontend/src/models/graph.ts @@ -149,6 +149,10 @@ export class Graph { return !!sourceNode.outputs.find(id => id === destination); } + isReachable(source: NodeID, destination: NodeID): boolean { + return this.expandAllOutputs([source]).includes(destination); + } + rootNodes(): NodeID[] { return [...this.nodes.keys()].filter(id => !this.nodes.get(id)?.inputs.length); } diff --git a/rsconcept/frontend/src/utils/labels.ts b/rsconcept/frontend/src/utils/labels.ts index 2b89e347..631c7a37 100644 --- a/rsconcept/frontend/src/utils/labels.ts +++ b/rsconcept/frontend/src/utils/labels.ts @@ -70,7 +70,11 @@ export const errorMsg = { invalidLocation: 'Некорректный формат пути', emptySubstitutions: 'Выберите хотя бы одно отождествление', invalidResponse: 'Некорректный ответ сервера', - connectionExists: 'Связь уже существует' + connectionExists: 'Связь уже существует', + cyclingEdge: 'Связь образует цикл', + changeInheritedDefinition: 'Нельзя изменить определение наследника', + addInheritedEdge: 'Новая связь между наследниками из одной схемы не допускается', + deleteInheritedEdge: 'Нельзя удалить связь между наследниками из одной схемы' } as const; /**