diff --git a/rsconcept/backend/apps/oss/models/OperationSchemaCached.py b/rsconcept/backend/apps/oss/models/OperationSchemaCached.py index 0ab79ae2..66ea0687 100644 --- a/rsconcept/backend/apps/oss/models/OperationSchemaCached.py +++ b/rsconcept/backend/apps/oss/models/OperationSchemaCached.py @@ -4,7 +4,7 @@ from typing import Optional from apps.library.models import LibraryItem -from apps.rsform.models import Constituenta, CstType, OrderManager, RSFormCached +from apps.rsform.models import Association, Constituenta, CstType, OrderManager, RSFormCached from .Argument import Argument from .Inheritance import Inheritance @@ -283,15 +283,15 @@ class OperationSchemaCached: mapping=alias_mapping ) - def before_delete_cst(self, sourceID: int, target: list[int]) -> None: + def before_delete_cst(self, operationID: int, target: list[int]) -> None: ''' Trigger cascade resolutions before Constituents are deleted. ''' - operation = self.cache.get_operation(sourceID) + operation = self.cache.get_operation(operationID) self.engine.on_delete_inherited(operation.pk, target) def before_substitute(self, schemaID: int, substitutions: CstSubstitution) -> None: ''' Trigger cascade resolutions before Constituents are substituted. ''' operation = self.cache.get_operation(schemaID) - self.engine.on_before_substitute(substitutions, operation) + self.engine.on_before_substitute(operation.pk, substitutions) def before_delete_arguments(self, target: Operation, arguments: list[Operation]) -> None: ''' Trigger cascade resolutions before arguments are deleted. ''' @@ -318,6 +318,17 @@ class OperationSchemaCached: mapping={} ) + def after_create_association(self, schemaID: int, associations: list[Association], + exclude: Optional[list[int]] = None) -> None: + ''' Trigger cascade resolutions when association is created. ''' + operation = self.cache.get_operation(schemaID) + self.engine.on_inherit_association(operation.pk, associations, exclude) + + def before_delete_association(self, schemaID: int, associations: list[Association]) -> None: + ''' Trigger cascade resolutions when association is deleted. ''' + operation = self.cache.get_operation(schemaID) + self.engine.on_delete_association(operation.pk, associations) + def _on_add_substitutions(self, schema: Optional[RSFormCached], added: list[Substitution]) -> None: ''' Trigger cascade resolutions when Constituenta substitution is added. ''' if not added: diff --git a/rsconcept/backend/apps/oss/models/PropagationEngine.py b/rsconcept/backend/apps/oss/models/PropagationEngine.py index c45fccc0..86a07d4d 100644 --- a/rsconcept/backend/apps/oss/models/PropagationEngine.py +++ b/rsconcept/backend/apps/oss/models/PropagationEngine.py @@ -3,7 +3,7 @@ from typing import Optional from rest_framework.serializers import ValidationError -from apps.rsform.models import INSERT_LAST, Constituenta, CstType, RSFormCached +from apps.rsform.models import INSERT_LAST, Association, Constituenta, CstType, RSFormCached from .Inheritance import Inheritance from .Operation import Operation @@ -126,9 +126,41 @@ class PropagationEngine: mapping=new_mapping ) - def on_before_substitute(self, substitutions: CstSubstitution, operation: Operation) -> None: + def on_inherit_association(self, operationID: int, + items: list[Association], + exclude: Optional[list[int]] = None) -> None: + ''' Trigger cascade resolutions when association is inherited. ''' + children = self.cache.extend_graph.outputs[operationID] + if not children: + return + for child_id in children: + if not exclude or child_id not in exclude: + self.inherit_association(child_id, items) + + def inherit_association(self, target: int, items: list[Association]) -> None: + ''' Execute inheritance of Associations. ''' + operation = self.cache.operation_by_id[target] + if operation.result is None: + return + + self.cache.ensure_loaded_subs() + new_associations: list[Association] = [] + for assoc in items: + new_container = self.cache.get_inheritor(assoc.container_id, target) + new_associate = self.cache.get_inheritor(assoc.associate_id, target) + if new_container is None or new_associate is None: + continue + new_associations.append(Association( + container_id=new_container, + associate_id=new_associate + )) + if new_associations: + new_associations = Association.objects.bulk_create(new_associations) + self.on_inherit_association(target, new_associations) + + def on_before_substitute(self, operationID: int, substitutions: CstSubstitution) -> None: ''' Trigger cascade resolutions when Constituenta substitution is executed. ''' - children = self.cache.extend_graph.outputs[operation.pk] + children = self.cache.extend_graph.outputs[operationID] if not children: return self.cache.ensure_loaded_subs() @@ -140,9 +172,37 @@ class PropagationEngine: new_substitutions = self._transform_substitutions(substitutions, child_id, child_schema) if not new_substitutions: continue - self.on_before_substitute(new_substitutions, child_operation) + self.on_before_substitute(child_operation.pk, new_substitutions) child_schema.substitute(new_substitutions) + def on_delete_association(self, operationID: int, associations: list[Association]) -> None: + ''' Trigger cascade resolutions when association is deleted. ''' + children = self.cache.extend_graph.outputs[operationID] + if not children: + return + self.cache.ensure_loaded_subs() + for child_id in children: + child_operation = self.cache.operation_by_id[child_id] + child_schema = self.cache.get_schema(child_operation) + if child_schema is None: + continue + + deleted: list[Association] = [] + for assoc in associations: + new_container = self.cache.get_inheritor(assoc.container_id, child_id) + new_associate = self.cache.get_inheritor(assoc.associate_id, child_id) + if new_container is None or new_associate is None: + continue + deleted_assoc = Association.objects.filter( + container=new_container, + associate=new_associate + ) + if deleted_assoc.exists(): + deleted.append(deleted_assoc[0]) + if deleted: + self.on_delete_association(child_id, deleted) + Association.objects.filter(pk__in=[assoc.pk for assoc in deleted]).delete() + def on_delete_inherited(self, operation: int, target: list[int]) -> None: ''' Trigger cascade resolutions when Constituenta inheritance is deleted. ''' children = self.cache.extend_graph.outputs[operation] diff --git a/rsconcept/backend/apps/oss/models/PropagationFacade.py b/rsconcept/backend/apps/oss/models/PropagationFacade.py index 0c508672..68520781 100644 --- a/rsconcept/backend/apps/oss/models/PropagationFacade.py +++ b/rsconcept/backend/apps/oss/models/PropagationFacade.py @@ -2,7 +2,7 @@ from typing import Optional from apps.library.models import LibraryItem, LibraryItemType -from apps.rsform.models import Constituenta, CstType, RSFormCached +from apps.rsform.models import Association, Constituenta, CstType, RSFormCached from .OperationSchemaCached import CstSubstitution, OperationSchemaCached @@ -80,3 +80,22 @@ class PropagationFacade: for host in hosts: if exclude is None or host.pk not in exclude: OperationSchemaCached(host).before_delete_cst(item.pk, ids) + + @staticmethod + def after_create_association(sourceID: int, associations: list[Association], + exclude: Optional[list[int]] = None) -> None: + ''' Trigger cascade resolutions when association is created. ''' + hosts = _get_oss_hosts(sourceID) + for host in hosts: + if exclude is None or host.pk not in exclude: + OperationSchemaCached(host).after_create_association(sourceID, associations) + + @staticmethod + def before_delete_association(sourceID: int, + associations: list[Association], + exclude: Optional[list[int]] = None) -> None: + ''' Trigger cascade resolutions before association is deleted. ''' + hosts = _get_oss_hosts(sourceID) + for host in hosts: + if exclude is None or host.pk not in exclude: + OperationSchemaCached(host).before_delete_association(sourceID, associations) diff --git a/rsconcept/backend/apps/rsform/serializers/__init__.py b/rsconcept/backend/apps/rsform/serializers/__init__.py index 6fd2703e..7dfe7c4c 100644 --- a/rsconcept/backend/apps/rsform/serializers/__init__.py +++ b/rsconcept/backend/apps/rsform/serializers/__init__.py @@ -12,6 +12,7 @@ from .basics import ( WordFormSerializer ) from .data_access import ( + AssociationDataSerializer, CrucialUpdateSerializer, CstCreateSerializer, CstInfoSerializer, diff --git a/rsconcept/backend/apps/rsform/serializers/data_access.py b/rsconcept/backend/apps/rsform/serializers/data_access.py index e20e5c5c..18d84e2e 100644 --- a/rsconcept/backend/apps/rsform/serializers/data_access.py +++ b/rsconcept/backend/apps/rsform/serializers/data_access.py @@ -30,6 +30,25 @@ class AssociationSerializer(StrictModelSerializer): fields = ('container', 'associate') +class AssociationDataSerializer(StrictSerializer): + ''' Serializer: Association data. ''' + container = PKField(many=False, queryset=Constituenta.objects.all().only('schema_id')) + associate = PKField(many=False, queryset=Constituenta.objects.all().only('schema_id')) + + def validate(self, attrs): + schema = cast(LibraryItem, self.context['schema']) + if schema and attrs['container'].schema_id != schema.id: + raise serializers.ValidationError({ + 'container': msg.constituentaNotInRSform(schema.title) + }) + if schema and attrs['associate'].schema_id != schema.id: + raise serializers.ValidationError({ + 'associate': msg.constituentaNotInRSform(schema.title) + }) + + return attrs + + class CstBaseSerializer(StrictModelSerializer): ''' Serializer: Constituenta all data. ''' class Meta: diff --git a/rsconcept/backend/apps/rsform/views/rsforms.py b/rsconcept/backend/apps/rsform/views/rsforms.py index 1eb1ecf2..8f1c9901 100644 --- a/rsconcept/backend/apps/rsform/views/rsforms.py +++ b/rsconcept/backend/apps/rsform/views/rsforms.py @@ -49,6 +49,9 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr 'restore_order', 'reset_aliases', 'produce_structure', + 'add_association', + 'delete_association', + 'clear_associations' ]: permission_list = [permissions.ItemEditor] elif self.action in [ @@ -281,6 +284,104 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr data=s.RSFormParseSerializer(item).data ) + @extend_schema( + summary='create Association', + tags=['Constituenta'], + request=s.AssociationDataSerializer, + responses={ + c.HTTP_201_CREATED: s.RSFormParseSerializer, + c.HTTP_400_BAD_REQUEST: None, + c.HTTP_403_FORBIDDEN: None, + c.HTTP_404_NOT_FOUND: None + } + ) + @action(detail=True, methods=['post'], url_path='create-association') + def create_association(self, request: Request, pk) -> HttpResponse: + ''' Create Association. ''' + item = self._get_item() + serializer = s.AssociationDataSerializer(data=request.data, context={'schema': item}) + serializer.is_valid(raise_exception=True) + + with transaction.atomic(): + new_association = m.Association.objects.create( + container=serializer.validated_data['container'], + associate=serializer.validated_data['associate'] + ) + PropagationFacade.after_create_association(item.pk, [new_association]) + item.save(update_fields=['time_update']) + + return Response( + status=c.HTTP_201_CREATED, + data=s.RSFormParseSerializer(item).data + ) + + @extend_schema( + summary='delete Association', + tags=['RSForm'], + request=s.AssociationDataSerializer, + responses={ + c.HTTP_200_OK: s.RSFormParseSerializer, + c.HTTP_400_BAD_REQUEST: None, + c.HTTP_403_FORBIDDEN: None, + c.HTTP_404_NOT_FOUND: None + } + ) + @action(detail=True, methods=['patch'], url_path='delete-association') + def delete_association(self, request: Request, pk) -> HttpResponse: + ''' Endpoint: Delete Association. ''' + item = self._get_item() + serializer = s.AssociationDataSerializer(data=request.data, context={'schema': item}) + serializer.is_valid(raise_exception=True) + + with transaction.atomic(): + target = list(m.Association.objects.filter( + container=serializer.validated_data['container'], + associate=serializer.validated_data['associate'] + )) + if not target: + raise ValidationError({ + 'container': msg.invalidAssociation() + }) + + PropagationFacade.before_delete_association(item.pk, target) + m.Association.objects.filter(pk__in=[assoc.pk for assoc in target]).delete() + item.save(update_fields=['time_update']) + + return Response( + status=c.HTTP_200_OK, + data=s.RSFormParseSerializer(item).data + ) + + @extend_schema( + summary='delete all associations for target constituenta', + tags=['RSForm'], + request=s.CstTargetSerializer, + responses={ + c.HTTP_200_OK: s.RSFormParseSerializer, + c.HTTP_400_BAD_REQUEST: None, + c.HTTP_403_FORBIDDEN: None, + c.HTTP_404_NOT_FOUND: None + } + ) + @action(detail=True, methods=['patch'], url_path='clear-associations') + def clear_associations(self, request: Request, pk) -> HttpResponse: + ''' Endpoint: Delete Associations for target Constituenta. ''' + item = self._get_item() + serializer = s.CstTargetSerializer(data=request.data, context={'schema': item}) + serializer.is_valid(raise_exception=True) + + with transaction.atomic(): + target = list(m.Association.objects.filter(container=serializer.validated_data['target'])) + if target: + PropagationFacade.before_delete_association(item.pk, target) + m.Association.objects.filter(pk__in=[assoc.pk for assoc in target]).delete() + item.save(update_fields=['time_update']) + + return Response( + status=c.HTTP_200_OK, + data=s.RSFormParseSerializer(item).data + ) + @extend_schema( summary='move constituenta', tags=['RSForm'], diff --git a/rsconcept/backend/shared/messages.py b/rsconcept/backend/shared/messages.py index c8cd72a9..398c6042 100644 --- a/rsconcept/backend/shared/messages.py +++ b/rsconcept/backend/shared/messages.py @@ -142,6 +142,10 @@ def typificationInvalidStr(): return 'Invalid typification string' +def invalidAssociation(): + return f'Ассоциация не найдена' + + def exteorFileVersionNotSupported(): return 'Некорректный формат файла Экстеор. Сохраните файл в новой версии' diff --git a/rsconcept/frontend/src/features/oss/dialogs/dlg-show-term-graph/tg-readonly-flow.tsx b/rsconcept/frontend/src/features/oss/dialogs/dlg-show-term-graph/tg-readonly-flow.tsx index 9b858922..060a82ea 100644 --- a/rsconcept/frontend/src/features/oss/dialogs/dlg-show-term-graph/tg-readonly-flow.tsx +++ b/rsconcept/frontend/src/features/oss/dialogs/dlg-show-term-graph/tg-readonly-flow.tsx @@ -3,12 +3,12 @@ import { useEffect, useState } from 'react'; import { type Edge, MarkerType, type Node, useEdgesState, useNodesState } from 'reactflow'; +import { type IConstituenta, type IRSForm } from '@/features/rsform'; import { TGEdgeTypes } from '@/features/rsform/components/term-graph/graph/tg-edge-types'; import { TGNodeTypes } from '@/features/rsform/components/term-graph/graph/tg-node-types'; import { SelectColoring } from '@/features/rsform/components/term-graph/select-coloring'; import { ToolbarFocusedCst } from '@/features/rsform/components/term-graph/toolbar-focused-cst'; import { applyLayout, produceFilteredGraph, type TGNodeData } from '@/features/rsform/models/graph-api'; -import { type IConstituenta, type IRSForm } from '@/features/rsform/models/rsform'; import { useTermGraphStore } from '@/features/rsform/stores/term-graph'; import { DiagramFlow, useReactFlow } from '@/components/flow/diagram-flow'; diff --git a/rsconcept/frontend/src/features/rsform/backend/api.ts b/rsconcept/frontend/src/features/rsform/backend/api.ts index ad590f57..6e6bc38d 100644 --- a/rsconcept/frontend/src/features/rsform/backend/api.ts +++ b/rsconcept/frontend/src/features/rsform/backend/api.ts @@ -5,6 +5,8 @@ import { DELAYS, KEYS } from '@/backend/configuration'; import { infoMsg } from '@/utils/labels'; import { + type IAssociationDataDTO, + type IAssociationTargetDTO, type ICheckConstituentaDTO, type IConstituentaCreatedResponse, type IConstituentaList, @@ -150,5 +152,33 @@ export const rsformsApi = { schema: schemaExpressionParse, endpoint: `/api/rsforms/${itemID}/check-constituenta`, request: { data: data } + }), + + createAssociation: ({ itemID, data }: { itemID: number; data: IAssociationDataDTO }) => + axiosPost({ + schema: schemaRSForm, + endpoint: `/api/rsforms/${itemID}/create-association`, + request: { + data: data, + successMessage: infoMsg.changesSaved + } + }), + deleteAssociation: ({ itemID, data }: { itemID: number; data: IAssociationDataDTO }) => + axiosPatch({ + schema: schemaRSForm, + endpoint: `/api/rsforms/${itemID}/delete-association`, + request: { + data: data, + successMessage: infoMsg.changesSaved + } + }), + clearAssociations: ({ itemID, data }: { itemID: number; data: IAssociationTargetDTO }) => + axiosPatch({ + schema: schemaRSForm, + endpoint: `/api/rsforms/${itemID}/clear-associations`, + request: { + data: data, + successMessage: infoMsg.changesSaved + } }) } as const; diff --git a/rsconcept/frontend/src/features/rsform/backend/types.ts b/rsconcept/frontend/src/features/rsform/backend/types.ts index 073154a0..37932430 100644 --- a/rsconcept/frontend/src/features/rsform/backend/types.ts +++ b/rsconcept/frontend/src/features/rsform/backend/types.ts @@ -94,6 +94,12 @@ export interface ICheckConstituentaDTO { /** Represents data, used in merging multiple {@link IConstituenta}. */ export type ISubstitutionsDTO = z.infer; +/** Represents data for creating or deleting an association. */ +export type IAssociationDataDTO = z.infer; + +/** Represents data for clearing all associations for a target constituenta. */ +export type IAssociationTargetDTO = z.infer; + /** Represents Constituenta list. */ export interface IConstituentaList { items: number[]; @@ -386,6 +392,15 @@ export const schemaSubstitutions = z.strictObject({ substitutions: z.array(schemaSubstituteConstituents).min(1, { message: errorMsg.emptySubstitutions }) }); +export const schemaAssociationData = z.strictObject({ + container: z.number(), + associate: z.number() +}); + +export const schemaAssociationTarget = z.strictObject({ + target: z.number() +}); + export const schemaInlineSynthesis = z.strictObject({ receiver: z.number(), source: z.number().nullable(), diff --git a/rsconcept/frontend/src/features/rsform/backend/use-clear-associations.ts b/rsconcept/frontend/src/features/rsform/backend/use-clear-associations.ts new file mode 100644 index 00000000..a572c7e8 --- /dev/null +++ b/rsconcept/frontend/src/features/rsform/backend/use-clear-associations.ts @@ -0,0 +1,29 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { useUpdateTimestamp } from '@/features/library/backend/use-update-timestamp'; + +import { KEYS } from '@/backend/configuration'; + +import { rsformsApi } from './api'; +import { type IAssociationTargetDTO } from './types'; + +export const useClearAssociations = () => { + const client = useQueryClient(); + const { updateTimestamp } = useUpdateTimestamp(); + const mutation = useMutation({ + mutationKey: [KEYS.global_mutation, rsformsApi.baseKey, 'clear-associations'], + mutationFn: rsformsApi.clearAssociations, + onSuccess: async data => { + updateTimestamp(data.id, data.time_update); + client.setQueryData(rsformsApi.getRSFormQueryOptions({ itemID: data.id }).queryKey, data); + await client.invalidateQueries({ + queryKey: [rsformsApi.baseKey], + predicate: query => query.queryKey.length > 2 && query.queryKey[2] !== String(data.id) + }); + }, + onError: () => client.invalidateQueries() + }); + return { + clearAssociations: (data: { itemID: number; data: IAssociationTargetDTO }) => mutation.mutateAsync(data) + }; +}; diff --git a/rsconcept/frontend/src/features/rsform/backend/use-create-association.ts b/rsconcept/frontend/src/features/rsform/backend/use-create-association.ts new file mode 100644 index 00000000..f266e05b --- /dev/null +++ b/rsconcept/frontend/src/features/rsform/backend/use-create-association.ts @@ -0,0 +1,29 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { useUpdateTimestamp } from '@/features/library/backend/use-update-timestamp'; + +import { KEYS } from '@/backend/configuration'; + +import { rsformsApi } from './api'; +import { type IAssociationDataDTO } from './types'; + +export const useCreateAssociation = () => { + const client = useQueryClient(); + const { updateTimestamp } = useUpdateTimestamp(); + const mutation = useMutation({ + mutationKey: [KEYS.global_mutation, rsformsApi.baseKey, 'create-association'], + mutationFn: rsformsApi.createAssociation, + onSuccess: async data => { + updateTimestamp(data.id, data.time_update); + client.setQueryData(rsformsApi.getRSFormQueryOptions({ itemID: data.id }).queryKey, data); + await client.invalidateQueries({ + queryKey: [rsformsApi.baseKey], + predicate: query => query.queryKey.length > 2 && query.queryKey[2] !== String(data.id) + }); + }, + onError: () => client.invalidateQueries() + }); + return { + createAssociation: (data: { itemID: number; data: IAssociationDataDTO }) => mutation.mutateAsync(data) + }; +}; diff --git a/rsconcept/frontend/src/features/rsform/backend/use-delete-association.ts b/rsconcept/frontend/src/features/rsform/backend/use-delete-association.ts new file mode 100644 index 00000000..35d68eb3 --- /dev/null +++ b/rsconcept/frontend/src/features/rsform/backend/use-delete-association.ts @@ -0,0 +1,29 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { useUpdateTimestamp } from '@/features/library/backend/use-update-timestamp'; + +import { KEYS } from '@/backend/configuration'; + +import { rsformsApi } from './api'; +import { type IAssociationDataDTO } from './types'; + +export const useDeleteAssociation = () => { + const client = useQueryClient(); + const { updateTimestamp } = useUpdateTimestamp(); + const mutation = useMutation({ + mutationKey: [KEYS.global_mutation, rsformsApi.baseKey, 'delete-association'], + mutationFn: rsformsApi.deleteAssociation, + onSuccess: async data => { + updateTimestamp(data.id, data.time_update); + client.setQueryData(rsformsApi.getRSFormQueryOptions({ itemID: data.id }).queryKey, data); + await client.invalidateQueries({ + queryKey: [rsformsApi.baseKey], + predicate: query => query.queryKey.length > 2 && query.queryKey[2] !== String(data.id) + }); + }, + onError: () => client.invalidateQueries() + }); + return { + deleteAssociation: (data: { itemID: number; data: IAssociationDataDTO }) => mutation.mutateAsync(data) + }; +};