F: Prepare Association backend api

This commit is contained in:
Ivan 2025-08-12 20:31:55 +03:00
parent 593b483936
commit 41e0ba64ba
13 changed files with 357 additions and 10 deletions

View File

@ -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:

View File

@ -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]

View File

@ -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)

View File

@ -12,6 +12,7 @@ from .basics import (
WordFormSerializer
)
from .data_access import (
AssociationDataSerializer,
CrucialUpdateSerializer,
CstCreateSerializer,
CstInfoSerializer,

View File

@ -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:

View File

@ -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'],

View File

@ -142,6 +142,10 @@ def typificationInvalidStr():
return 'Invalid typification string'
def invalidAssociation():
return f'Ассоциация не найдена'
def exteorFileVersionNotSupported():
return 'Некорректный формат файла Экстеор. Сохраните файл в новой версии'

View File

@ -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';

View File

@ -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<IAssociationDataDTO, IRSFormDTO>({
schema: schemaRSForm,
endpoint: `/api/rsforms/${itemID}/create-association`,
request: {
data: data,
successMessage: infoMsg.changesSaved
}
}),
deleteAssociation: ({ itemID, data }: { itemID: number; data: IAssociationDataDTO }) =>
axiosPatch<IAssociationDataDTO, IRSFormDTO>({
schema: schemaRSForm,
endpoint: `/api/rsforms/${itemID}/delete-association`,
request: {
data: data,
successMessage: infoMsg.changesSaved
}
}),
clearAssociations: ({ itemID, data }: { itemID: number; data: IAssociationTargetDTO }) =>
axiosPatch<IAssociationTargetDTO, IRSFormDTO>({
schema: schemaRSForm,
endpoint: `/api/rsforms/${itemID}/clear-associations`,
request: {
data: data,
successMessage: infoMsg.changesSaved
}
})
} as const;

View File

@ -94,6 +94,12 @@ export interface ICheckConstituentaDTO {
/** Represents data, used in merging multiple {@link IConstituenta}. */
export type ISubstitutionsDTO = z.infer<typeof schemaSubstitutions>;
/** Represents data for creating or deleting an association. */
export type IAssociationDataDTO = z.infer<typeof schemaAssociationData>;
/** Represents data for clearing all associations for a target constituenta. */
export type IAssociationTargetDTO = z.infer<typeof schemaAssociationTarget>;
/** 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(),

View File

@ -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)
};
};

View File

@ -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)
};
};

View File

@ -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)
};
};