diff --git a/rsconcept/backend/.pylintrc b/rsconcept/backend/.pylintrc index 831045e9..01740861 100644 --- a/rsconcept/backend/.pylintrc +++ b/rsconcept/backend/.pylintrc @@ -432,7 +432,8 @@ disable=too-many-public-methods, missing-function-docstring, attribute-defined-outside-init, ungrouped-imports, - abstract-method + abstract-method, + fixme # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option diff --git a/rsconcept/backend/apps/oss/models/OperationSchema.py b/rsconcept/backend/apps/oss/models/OperationSchema.py index ec6ff667..4d8498e9 100644 --- a/rsconcept/backend/apps/oss/models/OperationSchema.py +++ b/rsconcept/backend/apps/oss/models/OperationSchema.py @@ -5,10 +5,12 @@ from django.core.exceptions import ValidationError from django.db import transaction from django.db.models import QuerySet -from apps.library.models import LibraryItem, LibraryItemType +from apps.library.models import Editor, LibraryItem, LibraryItemType +from apps.rsform.models import RSForm from shared import messages as msg from .Argument import Argument +from .Inheritance import Inheritance from .Operation import Operation from .Substitution import Substitution @@ -76,8 +78,8 @@ class OperationSchema: ''' Delete operation. ''' operation.delete() - # deal with attached schema - # trigger on_change effects + # TODO: deal with attached schema + # TODO: trigger on_change effects self.save() @@ -86,16 +88,15 @@ class OperationSchema: ''' Set input schema for operation. ''' if schema == target.result: return - if schema: + target.result = schema + if schema is not None: target.result = schema target.alias = schema.alias target.title = schema.title target.comment = schema.comment - else: - target.result = None target.save() - # trigger on_change effects + # TODO: trigger on_change effects self.save() @@ -117,7 +118,7 @@ class OperationSchema: Argument.objects.create(operation=operation, argument=arg) if not changed: return - # trigger on_change effects + # TODO: trigger on_change effects self.save() @transaction.atomic @@ -148,6 +149,63 @@ class OperationSchema: if not changed: return - # trigger on_change effects + # TODO: trigger on_change effects self.save() + + @transaction.atomic + def create_input(self, operation: Operation) -> RSForm: + ''' Create input RSForm. ''' + schema = RSForm.create( + owner=self.model.owner, + alias=operation.alias, + title=operation.title, + comment=operation.comment, + visible=False, + access_policy=self.model.access_policy, + location=self.model.location + ) + Editor.set(schema.model, self.model.editors()) + operation.result = schema.model + operation.save() + self.save() + return schema + + @transaction.atomic + def execute_operation(self, operation: Operation) -> bool: + ''' Execute target operation. ''' + schemas: list[LibraryItem] = [arg.argument.result for arg in operation.getArguments()] + if None in schemas: + return False + substitutions = operation.getSubstitutions() + receiver = self.create_input(operation) + + parents: dict = {} + children: dict = {} + for operand in schemas: + schema = RSForm(operand) + items = list(schema.constituents()) + new_items = receiver.insert_copy(items) + for (i, cst) in enumerate(new_items): + parents[cst.pk] = items[i] + children[items[i].pk] = cst + + for sub in substitutions: + original = children[sub.original.pk] + replacement = children[sub.substitution.pk] + receiver.substitute(original, replacement) + + # TODO: remove duplicates from diamond + + for cst in receiver.constituents(): + parent = parents.get(cst.id) + assert parent is not None + Inheritance.objects.create( + child=cst, + parent=parent + ) + + receiver.restore_order() + receiver.reset_aliases() + self.save() + return True diff --git a/rsconcept/backend/apps/oss/serializers/data_access.py b/rsconcept/backend/apps/oss/serializers/data_access.py index cf74bde1..9c4e7de6 100644 --- a/rsconcept/backend/apps/oss/serializers/data_access.py +++ b/rsconcept/backend/apps/oss/serializers/data_access.py @@ -92,8 +92,10 @@ class OperationUpdateSerializer(serializers.Serializer): if 'substitutions' not in attrs: return attrs schemas = [arg.result.pk for arg in attrs['arguments'] if arg.result is not None] + substitutions = attrs['substitutions'] + to_delete = {x['original'].pk for x in substitutions} deleted = set() - for item in attrs['substitutions']: + for item in substitutions: original_cst = cast(Constituenta, item['original']) substitution_cst = cast(Constituenta, item['substitution']) if original_cst.schema.pk not in schemas: @@ -104,7 +106,7 @@ class OperationUpdateSerializer(serializers.Serializer): raise serializers.ValidationError({ f'{substitution_cst.id}': msg.constituentaNotFromOperation() }) - if original_cst.pk in deleted: + if original_cst.pk in deleted or substitution_cst.pk in to_delete: raise serializers.ValidationError({ f'{original_cst.id}': msg.substituteDouble(original_cst.alias) }) 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 ee78abfd..2d669579 100644 --- a/rsconcept/backend/apps/oss/tests/s_views/t_oss.py +++ b/rsconcept/backend/apps/oss/tests/s_views/t_oss.py @@ -23,10 +23,27 @@ class TestOssViewset(EndpointTester): def populateData(self): - self.ks1 = RSForm.create(alias='KS1', title='Test1', owner=self.user) - self.ks1x1 = self.ks1.insert_new('X1', term_resolved='X1_1') - self.ks2 = RSForm.create(alias='KS2', title='Test2', owner=self.user) - self.ks2x1 = self.ks2.insert_new('X2', term_resolved='X1_2') + self.ks1 = RSForm.create( + alias='KS1', + title='Test1', + owner=self.user + ) + self.ks1x1 = self.ks1.insert_new( + 'X1', + term_raw='X1_1', + term_resolved='X1_1' + ) + self.ks2 = RSForm.create( + alias='KS2', + title='Test2', + owner=self.user + ) + self.ks2x1 = self.ks2.insert_new( + 'X2', + term_raw='X1_2', + term_resolved='X1_2' + ) + self.operation1 = self.owned.create_operation( alias='1', operation_type=OperationType.INPUT, @@ -399,3 +416,61 @@ class TestOssViewset(EndpointTester): self.assertEqual(self.operation1.result.alias, data['item_data']['alias']) self.assertEqual(self.operation1.result.title, data['item_data']['title']) self.assertEqual(self.operation1.result.comment, data['item_data']['comment']) + + @decl_endpoint('/api/oss/{item}/update-operation', method='patch') + def test_update_operation_invalid_substitution(self): + self.populateData() + + self.ks1x2 = self.ks1.insert_new('X2') + + data = { + 'target': self.operation3.pk, + 'item_data': { + 'alias': 'Test3 mod', + 'title': 'Test title mod', + 'comment': 'Comment mod' + }, + 'positions': [], + 'arguments': [self.operation1.pk, self.operation2.pk], + 'substitutions': [ + { + 'original': self.ks1x1.pk, + 'substitution': self.ks2x1.pk + }, + { + 'original': self.ks2x1.pk, + 'substitution': self.ks1x2.pk + } + ] + } + self.executeBadData(data=data, item=self.owned_id) + + @decl_endpoint('/api/oss/{item}/execute-operation', method='post') + def test_execute_operation(self): + self.populateData() + self.executeBadData(item=self.owned_id) + + data = { + 'positions': [], + 'target': self.operation1.pk + } + self.executeBadData(data=data) + + data['target'] = self.operation3.pk + self.toggle_admin(True) + self.executeBadData(data=data, item=self.unowned_id) + self.logout() + self.executeForbidden(data=data, item=self.owned_id) + + self.login() + self.executeOK(data=data) + self.operation3.refresh_from_db() + schema = self.operation3.result + self.assertEqual(schema.alias, self.operation3.alias) + self.assertEqual(schema.comment, self.operation3.comment) + self.assertEqual(schema.title, self.operation3.title) + self.assertEqual(schema.visible, False) + items = list(RSForm(schema).constituents()) + self.assertEqual(len(items), 1) + self.assertEqual(items[0].alias, 'X1') + self.assertEqual(items[0].term_resolved, self.ks2x1.term_resolved) diff --git a/rsconcept/backend/apps/oss/views/oss.py b/rsconcept/backend/apps/oss/views/oss.py index 40a52495..e436ec9b 100644 --- a/rsconcept/backend/apps/oss/views/oss.py +++ b/rsconcept/backend/apps/oss/views/oss.py @@ -10,7 +10,7 @@ from rest_framework.decorators import action from rest_framework.request import Request from rest_framework.response import Response -from apps.library.models import Editor, LibraryItem, LibraryItemType +from apps.library.models import LibraryItem, LibraryItemType from apps.library.serializers import LibraryItemSerializer from shared import messages as msg from shared import permissions @@ -38,7 +38,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev 'create_input', 'set_input', 'update_operation', - 'execute_operation', + 'execute_operation' ]: permission_list = [permissions.ItemEditor] elif self.action in ['details']: @@ -103,28 +103,14 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev oss = m.OperationSchema(self.get_object()) with transaction.atomic(): oss.update_positions(serializer.validated_data['positions']) - data: dict = serializer.validated_data['item_data'] - if data['operation_type'] == m.OperationType.INPUT and serializer.validated_data['create_schema']: - schema = LibraryItem.objects.create( - item_type=LibraryItemType.RSFORM, - owner=oss.model.owner, - alias=data['alias'], - title=data['title'], - comment=data['comment'], - visible=False, - access_policy=oss.model.access_policy, - location=oss.model.location - ) - Editor.set(schema, oss.model.editors()) - data['result'] = schema - new_operation = oss.create_operation(**data) + new_operation = oss.create_operation(**serializer.validated_data['item_data']) + if new_operation.operation_type == m.OperationType.INPUT and serializer.validated_data['create_schema']: + oss.create_input(new_operation) if new_operation.operation_type != m.OperationType.INPUT and 'arguments' in serializer.validated_data: oss.set_arguments( operation=new_operation, arguments=serializer.validated_data['arguments'] ) - - oss.refresh_from_db() return Response( status=c.HTTP_201_CREATED, data={ @@ -158,7 +144,6 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev oss.update_positions(serializer.validated_data['positions']) oss.delete_operation(serializer.validated_data['target']) - oss.refresh_from_db() return Response( status=c.HTTP_200_OK, data=s.OperationSchemaSerializer(oss.model).data @@ -197,25 +182,12 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev oss = m.OperationSchema(self.get_object()) with transaction.atomic(): oss.update_positions(serializer.validated_data['positions']) - schema = LibraryItem.objects.create( - item_type=LibraryItemType.RSFORM, - owner=oss.model.owner, - alias=operation.alias, - title=operation.title, - comment=operation.comment, - visible=False, - access_policy=oss.model.access_policy, - location=oss.model.location - ) - Editor.set(schema, oss.model.editors()) - operation.result = schema - operation.save() + schema = oss.create_input(operation) - oss.refresh_from_db() return Response( status=c.HTTP_200_OK, data={ - 'new_schema': LibraryItemSerializer(schema).data, + 'new_schema': LibraryItemSerializer(schema.model).data, 'oss': s.OperationSchemaSerializer(oss.model).data } ) @@ -241,20 +213,10 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev serializer.is_valid(raise_exception=True) operation: m.Operation = cast(m.Operation, serializer.validated_data['target']) - result = serializer.validated_data['input'] oss = m.OperationSchema(self.get_object()) with transaction.atomic(): oss.update_positions(serializer.validated_data['positions']) - operation.result = result - if result is not None: - operation.title = result.title - operation.comment = result.comment - operation.alias = result.alias - operation.save() - - # update arguments - - oss.refresh_from_db() + oss.set_input(operation, serializer.validated_data['input']) return Response( status=c.HTTP_200_OK, data=s.OperationSchemaSerializer(oss.model).data @@ -336,17 +298,10 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev }) oss = m.OperationSchema(self.get_object()) - # with transaction.atomic(): - # oss.update_positions(serializer.validated_data['positions']) - # operation.result.refresh_from_db() - # operation.result.title = operation.title - # operation.result.comment = operation.comment - # operation.result.alias = operation.alias - # operation.result.save() + with transaction.atomic(): + oss.update_positions(serializer.validated_data['positions']) + oss.execute_operation(operation) - # update arguments - - oss.refresh_from_db() return Response( status=c.HTTP_200_OK, data=s.OperationSchemaSerializer(oss.model).data diff --git a/rsconcept/backend/apps/rsform/models/Constituenta.py b/rsconcept/backend/apps/rsform/models/Constituenta.py index 7f81f04d..6286023d 100644 --- a/rsconcept/backend/apps/rsform/models/Constituenta.py +++ b/rsconcept/backend/apps/rsform/models/Constituenta.py @@ -12,7 +12,6 @@ from django.db.models import ( TextChoices, TextField ) -from django.urls import reverse from ..utils import apply_pattern diff --git a/rsconcept/backend/apps/rsform/views/rsforms.py b/rsconcept/backend/apps/rsform/views/rsforms.py index 85abd124..cc88af5d 100644 --- a/rsconcept/backend/apps/rsform/views/rsforms.py +++ b/rsconcept/backend/apps/rsform/views/rsforms.py @@ -119,7 +119,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr cst = m.Constituenta.objects.get(pk=request.data['id']) if cst.schema != schema: raise ValidationError({ - f'schema': msg.constituentaNotInRSform(schema.title) + 'schema': msg.constituentaNotInRSform(schema.title) }) serializer.update(instance=cst, validated_data=serializer.validated_data) diff --git a/rsconcept/backend/shared/messages.py b/rsconcept/backend/shared/messages.py index 1b2fe157..3ca92eaf 100644 --- a/rsconcept/backend/shared/messages.py +++ b/rsconcept/backend/shared/messages.py @@ -14,6 +14,10 @@ def operationNotInOSS(title: str): return f'Операция не принадлежит ОСС: {title}' +def previousResultMissing(): + return 'Отсутствует результат предыдущей операции' + + def substitutionNotInList(): return 'Отождествляемая конституента отсутствует в списке' diff --git a/rsconcept/frontend/src/backend/oss.ts b/rsconcept/frontend/src/backend/oss.ts index 8273bbc8..05c8a359 100644 --- a/rsconcept/frontend/src/backend/oss.ts +++ b/rsconcept/frontend/src/backend/oss.ts @@ -73,10 +73,3 @@ export function postExecuteOperation(oss: string, request: FrontExchange) { - AxiosPost({ - endpoint: `/api/oss/${oss}/execute-all`, - request: request - }); -} diff --git a/rsconcept/frontend/src/components/select/PickSubstitutions.tsx b/rsconcept/frontend/src/components/select/PickSubstitutions.tsx index 76e73d1b..706dc334 100644 --- a/rsconcept/frontend/src/components/select/PickSubstitutions.tsx +++ b/rsconcept/frontend/src/components/select/PickSubstitutions.tsx @@ -1,6 +1,7 @@ 'use client'; import { useCallback, useMemo, useState } from 'react'; +import { toast } from 'react-toastify'; import BadgeConstituenta from '@/components/info/BadgeConstituenta'; import SelectConstituenta from '@/components/select/SelectConstituenta'; @@ -10,6 +11,7 @@ import { useConceptOptions } from '@/context/ConceptOptionsContext'; import { ILibraryItem } from '@/models/library'; import { ICstSubstitute, IMultiSubstitution } from '@/models/oss'; import { ConstituentaID, IConstituenta, IRSForm } from '@/models/rsform'; +import { errors } from '@/utils/labels'; import { IconPageLeft, IconPageRight, IconRemove, IconReplace } from '../Icons'; import NoData from '../ui/NoData'; @@ -98,6 +100,18 @@ function PickSubstitutions({ original: deleteRight ? rightCst.id : leftCst.id, substitution: deleteRight ? leftCst.id : rightCst.id }; + const toDelete = substitutions.map(item => item.original); + const replacements = substitutions.map(item => item.substitution); + console.log(toDelete, replacements); + console.log(newSubstitution); + if ( + toDelete.includes(newSubstitution.original) || + toDelete.includes(newSubstitution.substitution) || + replacements.includes(newSubstitution.original) + ) { + toast.error(errors.reuseOriginal); + return; + } setSubstitutions(prev => [...prev, newSubstitution]); setLeftCst(undefined); setRightCst(undefined); diff --git a/rsconcept/frontend/src/context/OssContext.tsx b/rsconcept/frontend/src/context/OssContext.tsx index 1511ce07..f0c0c039 100644 --- a/rsconcept/frontend/src/context/OssContext.tsx +++ b/rsconcept/frontend/src/context/OssContext.tsx @@ -19,7 +19,6 @@ import { patchUpdateOperation, patchUpdatePositions, postCreateOperation, - postExecuteAll, postExecuteOperation } from '@/backend/oss'; import { type ErrorData } from '@/components/info/InfoError'; @@ -69,7 +68,6 @@ interface IOssContext { setInput: (data: IOperationSetInputData, callback?: () => void) => void; updateOperation: (data: IOperationUpdateData, callback?: () => void) => void; executeOperation: (data: ITargetOperation, callback?: () => void) => void; - executeAll: (data: IPositionsData, callback?: () => void) => void; } const OssContext = createContext(null); @@ -413,28 +411,6 @@ export const OssState = ({ itemID, children }: OssStateProps) => { [itemID, schema, library] ); - const executeAll = useCallback( - (data: IPositionsData, callback?: () => void) => { - if (!schema) { - return; - } - setProcessingError(undefined); - postExecuteAll(itemID, { - data: data, - showError: true, - setLoading: setProcessing, - onError: setProcessingError, - onSuccess: newData => { - library.setGlobalOSS(newData); - library.reloadItems(() => { - if (callback) callback(); - }); - } - }); - }, - [itemID, schema, library] - ); - return ( { createInput, setInput, updateOperation, - executeOperation, - executeAll + executeOperation }} > {children} diff --git a/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/OssFlow.tsx b/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/OssFlow.tsx index e2dbdc85..d0c8621a 100644 --- a/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/OssFlow.tsx +++ b/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/OssFlow.tsx @@ -170,12 +170,11 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) { ); const handleExecuteSelected = useCallback(() => { - if (controller.selected.length === 1) { - handleExecuteOperation(controller.selected[0]); - } else { - controller.executeAll(getPositions()); + if (controller.selected.length !== 1) { + return; } - }, [controller, handleExecuteOperation, getPositions]); + handleExecuteOperation(controller.selected[0]); + }, [controller, handleExecuteOperation]); const handleFitView = useCallback(() => { flow.fitView({ duration: PARAMETER.zoomDuration }); diff --git a/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/ToolbarOssGraph.tsx b/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/ToolbarOssGraph.tsx index c1df28f6..2a2b5b9d 100644 --- a/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/ToolbarOssGraph.tsx +++ b/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/ToolbarOssGraph.tsx @@ -1,4 +1,7 @@ +'use client'; + import clsx from 'clsx'; +import { useMemo } from 'react'; import { IconAnimation, @@ -18,6 +21,7 @@ import { import BadgeHelp from '@/components/info/BadgeHelp'; import MiniButton from '@/components/ui/MiniButton'; import { HelpTopic } from '@/models/miscellaneous'; +import { OperationType } from '@/models/oss'; import { PARAMETER } from '@/utils/constants'; import { prepareTooltip } from '@/utils/labels'; @@ -59,6 +63,30 @@ function ToolbarOssGraph({ toggleEdgeStraight }: ToolbarOssGraphProps) { const controller = useOssEdit(); + const selectedOperation = useMemo( + () => controller.schema?.operationByID.get(controller.selected[0]), + [controller.selected, controller.schema] + ); + const readyForSynthesis = useMemo(() => { + if (!selectedOperation || selectedOperation.operation_type !== OperationType.SYNTHESIS) { + return false; + } + if (!controller.schema || selectedOperation.result) { + return false; + } + + const argumentIDs = controller.schema.graph.expandInputs([selectedOperation.id]); + if (!argumentIDs || argumentIDs.length < 2) { + return false; + } + + const argumentOperations = argumentIDs.map(id => controller.schema!.operationByID.get(id)!); + if (argumentOperations.some(item => item.result === null)) { + return false; + } + + return true; + }, [selectedOperation, controller.schema]); return (
@@ -120,7 +148,6 @@ function ToolbarOssGraph({
{controller.isMutable ? (
- {' '} } @@ -134,14 +161,9 @@ function ToolbarOssGraph({ onClick={onCreate} /> - } - disabled={controller.isProcessing} + title='Выполнить операцию' + icon={} + disabled={controller.isProcessing || controller.selected.length !== 1 || !readyForSynthesis} onClick={onExecute} /> void; promptEditOperation: (target: OperationID, positions: IOperationPosition[]) => void; executeOperation: (target: OperationID, positions: IOperationPosition[]) => void; - executeAll: (positions: IOperationPosition[]) => void; } const OssEditContext = createContext(null); @@ -290,14 +289,6 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr [model] ); - const executeAll = useCallback( - (positions: IOperationPosition[]) => { - const data = { positions: positions }; - model.executeAll(data, () => toast.success(information.allOperationExecuted)); - }, - [model] - ); - return ( {model.schema ? ( diff --git a/rsconcept/frontend/src/utils/labels.ts b/rsconcept/frontend/src/utils/labels.ts index 27839cc2..d22fa72c 100644 --- a/rsconcept/frontend/src/utils/labels.ts +++ b/rsconcept/frontend/src/utils/labels.ts @@ -951,7 +951,8 @@ export const information = { export const errors = { astFailed: 'Невозможно построить дерево разбора', passwordsMismatch: 'Пароли не совпадают', - imageFailed: 'Ошибка при создании изображения' + imageFailed: 'Ошибка при создании изображения', + reuseOriginal: 'Повторное использование удаляемой конституенты при отождествлении' }; /**