F: Constituenta relocation pt2

This commit is contained in:
Ivan 2024-10-28 14:52:30 +03:00
parent 2e775463a9
commit f0af0db3f9
14 changed files with 393 additions and 40 deletions

View File

@ -267,7 +267,51 @@ class OperationSchema:
self.save(update_fields=['time_update'])
return True
def after_create_cst(self, source: RSForm, cst_list: list[Constituenta]) -> None:
def relocate_down(self, source: RSForm, destination: RSForm, items: list[Constituenta]):
''' Move list of constituents to specific schema inheritor. '''
self.cache.ensure_loaded()
self.cache.insert_schema(source)
self.cache.insert_schema(destination)
operation = self.cache.get_operation(destination.model.pk)
self._undo_substitutions_cst(items, operation, destination)
inheritance_to_delete = [item for item in self.cache.inheritance[operation.pk] if item.parent_id in items]
for item in inheritance_to_delete:
self.cache.remove_inheritance(item)
Inheritance.objects.filter(operation_id=operation.pk, parent__in=items).delete()
def relocate_up(self, source: RSForm, destination: RSForm, items: list[Constituenta]) -> list[Constituenta]:
''' Move list of constituents to specific schema upstream. '''
self.cache.ensure_loaded()
self.cache.insert_schema(source)
self.cache.insert_schema(destination)
operation = self.cache.get_operation(source.model.pk)
alias_mapping: dict[str, str] = {}
for item in self.cache.inheritance[operation.pk]:
if item.parent_id in destination.cache.by_id:
source_cst = source.cache.by_id[item.child_id]
destination_cst = destination.cache.by_id[item.parent_id]
alias_mapping[source_cst.alias] = destination_cst.alias
new_items = destination.insert_copy(items, initial_mapping=alias_mapping)
for index, cst in enumerate(new_items):
new_inheritance = Inheritance.objects.create(
operation=operation,
child=items[index],
parent=cst
)
self.cache.insert_inheritance(new_inheritance)
self.after_create_cst(destination, new_items, exclude=[operation.pk])
return new_items
def after_create_cst(
self, source: RSForm,
cst_list: list[Constituenta],
exclude: Optional[list[int]] = None
) -> None:
''' Trigger cascade resolutions when new constituent is created. '''
self.cache.insert_schema(source)
inserted_aliases = [cst.alias for cst in cst_list]
@ -281,7 +325,7 @@ class OperationSchema:
if cst is not None:
alias_mapping[alias] = cst
operation = self.cache.get_operation(source.model.pk)
self._cascade_inherit_cst(operation.pk, source, cst_list, alias_mapping)
self._cascade_inherit_cst(operation.pk, source, cst_list, alias_mapping, exclude)
def after_change_cst_type(self, source: RSForm, target: Constituenta) -> None:
''' Trigger cascade resolutions when constituenta type is changed. '''
@ -344,17 +388,19 @@ class OperationSchema:
mapping={}
)
# pylint: disable=too-many-arguments, too-many-positional-arguments
def _cascade_inherit_cst(
self,
target_operation: int,
self, target_operation: int,
source: RSForm,
items: list[Constituenta],
mapping: CstMapping
mapping: CstMapping,
exclude: Optional[list[int]] = None
) -> None:
children = self.cache.graph.outputs[target_operation]
if len(children) == 0:
return
for child_id in children:
if not exclude or child_id not in exclude:
self._execute_inherit_cst(child_id, source, items, mapping)
def _execute_inherit_cst(
@ -827,6 +873,10 @@ class OssCache:
''' Remove substitution from cache. '''
self.substitutions[target.operation_id].remove(target)
def remove_inheritance(self, target: Inheritance) -> None:
''' Remove inheritance from cache. '''
self.inheritance[target.operation_id].remove(target)
def unfold_sub(self, sub: Substitution) -> tuple[RSForm, RSForm, Constituenta, Constituenta]:
operation = self.operation_by_id[sub.operation_id]
parents = self.graph.inputs[operation.pk]

View File

@ -1,4 +1,6 @@
''' Models: Change propagation facade - managing all changes in OSS. '''
from typing import Optional
from apps.library.models import LibraryItem, LibraryItemType
from apps.rsform.models import Constituenta, RSForm
@ -14,42 +16,53 @@ class PropagationFacade:
''' Change propagation API. '''
@staticmethod
def after_create_cst(source: RSForm, new_cst: list[Constituenta]) -> None:
def after_create_cst(source: RSForm, new_cst: list[Constituenta], exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions when new constituent is created. '''
hosts = _get_oss_hosts(source.model)
for host in hosts:
if exclude is None or host.pk not in exclude:
OperationSchema(host).after_create_cst(source, new_cst)
@staticmethod
def after_change_cst_type(source: RSForm, target: Constituenta) -> None:
def after_change_cst_type(source: RSForm, target: Constituenta, exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions when constituenta type is changed. '''
hosts = _get_oss_hosts(source.model)
for host in hosts:
if exclude is None or host.pk not in exclude:
OperationSchema(host).after_change_cst_type(source, target)
@staticmethod
def after_update_cst(source: RSForm, target: Constituenta, data: dict, old_data: dict) -> None:
def after_update_cst(
source: RSForm,
target: Constituenta,
data: dict,
old_data: dict,
exclude: Optional[list[int]] = None
) -> None:
''' Trigger cascade resolutions when constituenta data is changed. '''
hosts = _get_oss_hosts(source.model)
for host in hosts:
if exclude is None or host.pk not in exclude:
OperationSchema(host).after_update_cst(source, target, data, old_data)
@staticmethod
def before_delete_cst(source: RSForm, target: list[Constituenta]) -> None:
def before_delete_cst(source: RSForm, target: list[Constituenta], exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions before constituents are deleted. '''
hosts = _get_oss_hosts(source.model)
for host in hosts:
if exclude is None or host.pk not in exclude:
OperationSchema(host).before_delete_cst(source, target)
@staticmethod
def before_substitute(source: RSForm, substitutions: CstSubstitution) -> None:
def before_substitute(source: RSForm, substitutions: CstSubstitution, exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions before constituents are substituted. '''
hosts = _get_oss_hosts(source.model)
for host in hosts:
if exclude is None or host.pk not in exclude:
OperationSchema(host).before_substitute(source, substitutions)
@staticmethod
def before_delete_schema(item: LibraryItem) -> None:
def before_delete_schema(item: LibraryItem, exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions before schema is deleted. '''
if item.item_type != LibraryItemType.RSFORM:
return
@ -58,4 +71,4 @@ class PropagationFacade:
return
schema = RSForm(item)
PropagationFacade.before_delete_cst(schema, list(schema.constituents().order_by('order')))
PropagationFacade.before_delete_cst(schema, list(schema.constituents().order_by('order')), exclude)

View File

@ -9,6 +9,7 @@ from .data_access import (
OperationSerializer,
OperationTargetSerializer,
OperationUpdateSerializer,
RelocateConstituentsSerializer,
SetOperationInputSerializer
)
from .responses import ConstituentaReferenceResponse, NewOperationResponse, NewSchemaResponse

View File

@ -11,7 +11,7 @@ from apps.rsform.models import Constituenta
from apps.rsform.serializers import SubstitutionSerializerBase
from shared import messages as msg
from ..models import Argument, Operation, OperationSchema, OperationType
from ..models import Argument, Inheritance, Operation, OperationSchema, OperationType
from .basics import OperationPositionSerializer, SubstitutionExSerializer
@ -118,8 +118,6 @@ class OperationUpdateSerializer(serializers.Serializer):
return attrs
class OperationTargetSerializer(serializers.Serializer):
''' Serializer: Target single operation. '''
target = PKField(many=False, queryset=Operation.objects.all().only('oss_id', 'result_id'))
@ -224,3 +222,62 @@ class OperationSchemaSerializer(serializers.ModelSerializer):
).order_by('pk'):
result['substitutions'].append(substitution)
return result
class RelocateConstituentsSerializer(serializers.Serializer):
''' Serializer: Relocate constituents. '''
destination = PKField(
many=False,
queryset=LibraryItem.objects.all().only('id')
)
items = PKField(
many=True,
allow_empty=False,
queryset=Constituenta.objects.all()
)
def validate(self, attrs):
attrs['destination'] = attrs['destination'].id
attrs['source'] = attrs['items'][0].schema_id
# TODO: check permissions for editing source and destination
if attrs['source'] == attrs['destination']:
raise serializers.ValidationError({
'destination': msg.sourceEqualDestination()
})
for cst in attrs['items']:
if cst.schema_id != attrs['source']:
raise serializers.ValidationError({
f'{cst.pk}': msg.constituentaNotInRSform(attrs['items'][0].schema.title)
})
if Inheritance.objects.filter(child__in=attrs['items']).exists():
raise serializers.ValidationError({
'items': msg.RelocatingInherited()
})
oss = LibraryItem.objects \
.filter(operations__result_id=attrs['destination']) \
.filter(operations__result_id=attrs['source']).only('id')
if not oss.exists():
raise serializers.ValidationError({
'destination': msg.schemasNotConnected()
})
attrs['oss'] = oss[0].pk
if Argument.objects.filter(
operation__result_id=attrs['destination'],
argument__result_id=attrs['source']
).exists():
attrs['move_down'] = True
elif Argument.objects.filter(
operation__result_id=attrs['source'],
argument__result_id=attrs['destination']
).exists():
attrs['move_down'] = False
else:
raise serializers.ValidationError({
'destination': msg.schemasNotConnected()
})
return attrs

View File

@ -339,3 +339,65 @@ class TestChangeOperations(EndpointTester):
self.ks5.refresh_from_db()
self.assertNotEqual(self.operation4.result, None)
self.assertEqual(self.ks5.constituents().count(), 8)
@decl_endpoint('/api/oss/relocate-constituents', method='post')
def test_relocate_constituents_up(self):
ks1_old_count = self.ks1.constituents().count()
ks4_old_count = self.ks4.constituents().count()
operation6 = self.owned.create_operation(
alias='6',
operation_type=OperationType.SYNTHESIS
)
self.owned.set_arguments(operation6.pk, [self.operation1, self.operation2])
self.owned.execute_operation(operation6)
operation6.refresh_from_db()
ks6 = RSForm(operation6.result)
ks6A1 = ks6.insert_new('A1')
ks6_old_count = ks6.constituents().count()
data = {
'destination': self.ks1.model.pk,
'items': [ks6A1.pk]
}
self.executeOK(data=data)
ks6.refresh_from_db()
self.ks1.refresh_from_db()
self.ks4.refresh_from_db()
self.assertEqual(ks6.constituents().count(), ks6_old_count)
self.assertEqual(self.ks1.constituents().count(), ks1_old_count + 1)
self.assertEqual(self.ks4.constituents().count(), ks4_old_count + 1)
@decl_endpoint('/api/oss/relocate-constituents', method='post')
def test_relocate_constituents_down(self):
ks1_old_count = self.ks1.constituents().count()
ks4_old_count = self.ks4.constituents().count()
operation6 = self.owned.create_operation(
alias='6',
operation_type=OperationType.SYNTHESIS
)
self.owned.set_arguments(operation6.pk, [self.operation1, self.operation2])
self.owned.execute_operation(operation6)
operation6.refresh_from_db()
ks6 = RSForm(operation6.result)
ks6_old_count = ks6.constituents().count()
data = {
'destination': ks6.model.pk,
'items': [self.ks1X2.pk]
}
self.executeOK(data=data)
ks6.refresh_from_db()
self.ks1.refresh_from_db()
self.ks4.refresh_from_db()
self.ks4D2.refresh_from_db()
self.ks5D4.refresh_from_db()
self.assertEqual(ks6.constituents().count(), ks6_old_count)
self.assertEqual(self.ks1.constituents().count(), ks1_old_count - 1)
self.assertEqual(self.ks4.constituents().count(), ks4_old_count - 1)
self.assertEqual(self.ks4D2.definition_formal, r'DEL X2 X3 S1 D1')
self.assertEqual(self.ks5D4.definition_formal, r'X1 X2 X3 S1 D1 D2 D3')

View File

@ -1,7 +1,7 @@
''' Testing API: Operation Schema. '''
from apps.library.models import AccessPolicy, Editor, LibraryItem, LibraryItemType
from apps.oss.models import Operation, OperationSchema, OperationType
from apps.rsform.models import RSForm
from apps.rsform.models import Constituenta, RSForm
from shared.EndpointTester import EndpointTester, decl_endpoint
@ -18,7 +18,6 @@ class TestOssViewset(EndpointTester):
self.private_id = self.private.model.pk
self.invalid_id = self.private.model.pk + 1337
def populateData(self):
self.ks1 = RSForm.create(
alias='KS1',
@ -135,7 +134,6 @@ class TestOssViewset(EndpointTester):
self.executeForbidden(data=data, item=self.unowned_id)
self.executeForbidden(data=data, item=self.private_id)
@decl_endpoint('/api/oss/{item}/create-operation', method='post')
def test_create_operation(self):
self.populateData()
@ -499,3 +497,87 @@ class TestOssViewset(EndpointTester):
self.assertEqual(len(items), 1)
self.assertEqual(items[0].alias, 'X1')
self.assertEqual(items[0].term_resolved, self.ks2X1.term_resolved)
@decl_endpoint('/api/oss/get-predecessor', method='post')
def test_get_predecessor(self):
self.populateData()
self.ks1X2 = self.ks1.insert_new('X2')
self.owned.execute_operation(self.operation3)
self.operation3.refresh_from_db()
self.ks3 = RSForm(self.operation3.result)
self.ks3X2 = Constituenta.objects.get(as_child__parent_id=self.ks1X2.pk)
self.executeBadData(data={'target': self.invalid_id})
response = self.executeOK(data={'target': self.ks1X1.pk})
self.assertEqual(response.data['id'], self.ks1X1.pk)
self.assertEqual(response.data['schema'], self.ks1.model.pk)
response = self.executeOK(data={'target': self.ks3X2.pk})
self.assertEqual(response.data['id'], self.ks1X2.pk)
self.assertEqual(response.data['schema'], self.ks1.model.pk)
@decl_endpoint('/api/oss/relocate-constituents', method='post')
def test_relocate_constituents(self):
self.populateData()
self.ks1X2 = self.ks1.insert_new('X2', convention='test')
self.owned.execute_operation(self.operation3)
self.operation3.refresh_from_db()
self.ks3 = RSForm(self.operation3.result)
self.ks3X2 = Constituenta.objects.get(as_child__parent_id=self.ks1X2.pk)
self.ks3X10 = self.ks3.insert_new('X10', convention='test2')
# invalid destination
data = {
'destination': self.invalid_id,
'items': []
}
self.executeBadData(data=data)
# empty items
data = {
'destination': self.ks1.model.pk,
'items': []
}
self.executeBadData(data=data)
# source == destination
data = {
'destination': self.ks1.model.pk,
'items': [self.ks1X1.pk]
}
self.executeBadData(data=data)
# moving inherited
data = {
'destination': self.ks1.model.pk,
'items': [self.ks3X2.pk]
}
self.executeBadData(data=data)
# source and destination are not connected
data = {
'destination': self.ks2.model.pk,
'items': [self.ks1X1.pk]
}
self.executeBadData(data=data)
data = {
'destination': self.ks3.model.pk,
'items': [self.ks1X2.pk]
}
self.ks3X2.refresh_from_db()
self.assertEqual(self.ks3X2.convention, 'test')
self.executeOK(data=data)
self.assertFalse(Constituenta.objects.filter(as_child__parent_id=self.ks1X2.pk).exists())
data = {
'destination': self.ks1.model.pk,
'items': [self.ks3X10.pk]
}
self.executeOK(data=data)
self.assertTrue(Constituenta.objects.filter(as_parent__child_id=self.ks3X10.pk).exists())
self.ks1X3 = Constituenta.objects.get(as_parent__child_id=self.ks3X10.pk)
self.assertEqual(self.ks1X3.convention, 'test2')

View File

@ -14,7 +14,7 @@ from rest_framework.response import Response
from apps.library.models import LibraryItem, LibraryItemType
from apps.library.serializers import LibraryItemSerializer
from apps.rsform.models import Constituenta
from apps.rsform.models import Constituenta, RSForm
from apps.rsform.serializers import CstTargetSerializer
from shared import messages as msg
from shared import permissions
@ -42,7 +42,8 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
'create_input',
'set_input',
'update_operation',
'execute_operation'
'execute_operation',
'relocate_constituents'
]:
permission_list = [permissions.ItemEditor]
elif self.action in ['details']:
@ -385,3 +386,36 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
'schema': cst.schema_id
}
)
@extend_schema(
summary='relocate constituents from one schema to another',
tags=['OSS'],
request=s.RelocateConstituentsSerializer(),
responses={
c.HTTP_200_OK: s.OperationSchemaSerializer,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=False, methods=['post'], url_path='relocate-constituents')
def relocate_constituents(self, request: Request) -> Response:
''' Relocate constituents from one schema to another. '''
serializer = s.RelocateConstituentsSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
oss = m.OperationSchema(LibraryItem.objects.get(pk=data['oss']))
source = RSForm(LibraryItem.objects.get(pk=data['source']))
destination = RSForm(LibraryItem.objects.get(pk=data['destination']))
with transaction.atomic():
if data['move_down']:
oss.relocate_down(source, destination, data['items'])
m.PropagationFacade.before_delete_cst(source, data['items'])
source.delete_cst(data['items'])
else:
new_items = oss.relocate_up(source, destination, data['items'])
m.PropagationFacade.after_create_cst(destination, new_items, exclude=[oss.model.pk])
return Response(status=c.HTTP_200_OK)

View File

@ -64,6 +64,7 @@ class RSForm:
def refresh_from_db(self) -> None:
''' Model wrapper. '''
self.model.refresh_from_db()
self.cache = RSFormCache(self)
def constituents(self) -> QuerySet[Constituenta]:
''' Get QuerySet containing all constituents of current RSForm. '''

View File

@ -38,6 +38,18 @@ def operationResultFromAnotherOSS():
return 'Схема является результатом другой ОСС'
def schemasNotConnected():
return 'Концептуальные схемы не связаны через ОСС'
def sourceEqualDestination():
return 'Схема-источник и схема-получатель не могут быть одинаковыми'
def RelocatingInherited():
return 'Невозможно переместить наследуемые конституенты'
def operationInputAlreadyConnected():
return 'Схема уже подключена к другой операции'

View File

@ -3,6 +3,7 @@
*/
import {
ICstRelocateData,
IInputCreatedResponse,
IOperationCreateData,
IOperationCreatedResponse,
@ -76,6 +77,13 @@ export function postExecuteOperation(oss: string, request: FrontExchange<ITarget
});
}
export function postRelocateConstituents(request: FrontPush<ICstRelocateData>) {
AxiosPost({
endpoint: `/api/oss/relocate-constituents`,
request: request
});
}
export function postFindPredecessor(request: FrontExchange<ITargetCst, IConstituentaReference>) {
AxiosPost({
endpoint: `/api/oss/get-predecessor`,

View File

@ -17,12 +17,14 @@ import {
patchUpdateOperation,
patchUpdatePositions,
postCreateOperation,
postExecuteOperation
postExecuteOperation,
postRelocateConstituents
} from '@/backend/oss';
import { type ErrorData } from '@/components/info/InfoError';
import { AccessPolicy, ILibraryItem } from '@/models/library';
import { ILibraryUpdateData } from '@/models/library';
import {
ICstRelocateData,
IOperationCreateData,
IOperationData,
IOperationDeleteData,
@ -65,6 +67,7 @@ interface IOssContext {
setInput: (data: IOperationSetInputData, callback?: () => void) => void;
updateOperation: (data: IOperationUpdateData, callback?: () => void) => void;
executeOperation: (data: ITargetOperation, callback?: () => void) => void;
relocateConstituents: (data: ICstRelocateData, callback?: () => void) => void;
}
const OssContext = createContext<IOssContext | null>(null);
@ -353,6 +356,28 @@ export const OssState = ({ itemID, children }: React.PropsWithChildren<OssStateP
[itemID, model, library, oss]
);
const relocateConstituents = useCallback(
(data: ICstRelocateData, callback?: () => void) => {
if (!model) {
return;
}
setProcessingError(undefined);
postRelocateConstituents({
data: data,
showError: true,
setLoading: setProcessing,
onError: setProcessingError,
onSuccess: () => {
oss.reload();
library.reloadItems(() => {
if (callback) callback();
});
}
});
},
[model, library, oss]
);
return (
<OssContext.Provider
value={{
@ -376,7 +401,8 @@ export const OssState = ({ itemID, children }: React.PropsWithChildren<OssStateP
createInput,
setInput,
updateOperation,
executeOperation
executeOperation,
relocateConstituents
}}
>
{children}

View File

@ -55,12 +55,15 @@ function DlgRelocateConstituents({ oss, hideWindow, target, onSubmit }: DlgReloc
}, []);
const handleSubmit = useCallback(() => {
if (!destination) {
return;
}
const data: ICstRelocateData = {
destination: target.result ?? 0,
items: []
destination: destination.id,
items: selected
};
onSubmit(data);
}, [target, onSubmit]);
}, [destination, onSubmit, selected]);
return (
<Modal

View File

@ -199,9 +199,9 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
const handleRelocateConstituents = useCallback(
(target: OperationID) => {
controller.promptRelocateConstituents(target);
controller.promptRelocateConstituents(target, getPositions());
},
[controller]
[controller, getPositions]
);
const handleFitView = useCallback(() => {

View File

@ -76,7 +76,7 @@ export interface IOssEditContext extends ILibraryItemEditor {
promptEditInput: (target: OperationID, positions: IOperationPosition[]) => void;
promptEditOperation: (target: OperationID, positions: IOperationPosition[]) => void;
executeOperation: (target: OperationID, positions: IOperationPosition[]) => void;
promptRelocateConstituents: (target: OperationID) => void;
promptRelocateConstituents: (target: OperationID, positions: IOperationPosition[]) => void;
}
const OssEditContext = createContext<IOssEditContext | null>(null);
@ -360,16 +360,20 @@ export const OssEditState = ({ selected, setSelected, children }: React.PropsWit
[model]
);
const promptRelocateConstituents = useCallback((target: OperationID) => {
const promptRelocateConstituents = useCallback((target: OperationID, positions: IOperationPosition[]) => {
setPositions(positions);
setTargetOperationID(target);
setShowRelocateConstituents(true);
}, []);
const handleRelocateConstituents = useCallback((data: ICstRelocateData) => {
// TODO: implement backed call
console.log(data);
toast.success('В разработке');
}, []);
const handleRelocateConstituents = useCallback(
(data: ICstRelocateData) => {
model.savePositions({ positions: positions }, () =>
model.relocateConstituents(data, () => toast.success(information.changesSaved))
);
},
[model, positions]
);
return (
<OssEditContext.Provider