diff --git a/rsconcept/backend/apps/library/tests/s_views/t_library.py b/rsconcept/backend/apps/library/tests/s_views/t_library.py index ac1cd0be..a61a272d 100644 --- a/rsconcept/backend/apps/library/tests/s_views/t_library.py +++ b/rsconcept/backend/apps/library/tests/s_views/t_library.py @@ -68,8 +68,6 @@ class TestLibraryViewset(EndpointTester): self.assertEqual(response.data['access_policy'], data['access_policy']) self.assertEqual(response.data['visible'], data['visible']) self.assertEqual(response.data['read_only'], data['read_only']) - self.assertEqual(oss.layout().data['operations'], []) - self.assertEqual(oss.layout().data['blocks'], []) self.logout() data = {'title': 'Title2'} diff --git a/rsconcept/backend/apps/library/views/library.py b/rsconcept/backend/apps/library/views/library.py index 95d36064..99ffe1d2 100644 --- a/rsconcept/backend/apps/library/views/library.py +++ b/rsconcept/backend/apps/library/views/library.py @@ -41,7 +41,7 @@ class LibraryViewSet(viewsets.ModelViewSet): else: serializer.save() if serializer.data.get('item_type') == m.LibraryItemType.OPERATION_SCHEMA: - Layout.objects.create(oss=serializer.instance, data={'operations': [], 'blocks': []}) + Layout.objects.create(oss=serializer.instance, data=[]) def perform_update(self, serializer) -> None: instance = serializer.save() diff --git a/rsconcept/backend/apps/oss/migrations/0012 restructure_layout.py b/rsconcept/backend/apps/oss/migrations/0012 restructure_layout.py new file mode 100644 index 00000000..3fcb9628 --- /dev/null +++ b/rsconcept/backend/apps/oss/migrations/0012 restructure_layout.py @@ -0,0 +1,41 @@ +from django.db import migrations + + +def migrate_layout(apps, schema_editor): + Layout = apps.get_model('oss', 'Layout') + + for layout in Layout.objects.all(): + previous_data = layout.data + new_layout = [] + + for operation in previous_data['operations']: + new_layout.append({ + 'nodeID': 'o' + str(operation['id']), + 'x': operation['x'], + 'y': operation['y'], + 'width': 150, + 'height': 40 + }) + + for block in previous_data['blocks']: + new_layout.append({ + 'nodeID': 'b' + str(block['id']), + 'x': block['x'], + 'y': block['y'], + 'width': block['width'], + 'height': block['height'] + }) + + layout.data = new_layout + layout.save(update_fields=['data']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('oss', '0011_remove_operation_position_x_and_more'), + ] + + operations = [ + migrations.RunPython(migrate_layout), + ] diff --git a/rsconcept/backend/apps/oss/migrations/0013_alter_layout_data.py b/rsconcept/backend/apps/oss/migrations/0013_alter_layout_data.py new file mode 100644 index 00000000..2540749f --- /dev/null +++ b/rsconcept/backend/apps/oss/migrations/0013_alter_layout_data.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.1 on 2025-06-11 10:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oss', '0012 restructure_layout'), + ] + + operations = [ + migrations.AlterField( + model_name='layout', + name='data', + field=models.JSONField(default=list, verbose_name='Расположение'), + ), + ] diff --git a/rsconcept/backend/apps/oss/models/Layout.py b/rsconcept/backend/apps/oss/models/Layout.py index 1f6efdd1..fabd765a 100644 --- a/rsconcept/backend/apps/oss/models/Layout.py +++ b/rsconcept/backend/apps/oss/models/Layout.py @@ -13,7 +13,7 @@ class Layout(Model): data = JSONField( verbose_name='Расположение', - default=dict + default=list ) class Meta: diff --git a/rsconcept/backend/apps/oss/models/OperationSchema.py b/rsconcept/backend/apps/oss/models/OperationSchema.py index 79b1a7fc..8cd3b039 100644 --- a/rsconcept/backend/apps/oss/models/OperationSchema.py +++ b/rsconcept/backend/apps/oss/models/OperationSchema.py @@ -40,7 +40,7 @@ class OperationSchema: def create(**kwargs) -> 'OperationSchema': ''' Create LibraryItem via OperationSchema. ''' model = LibraryItem.objects.create(item_type=LibraryItemType.OPERATION_SCHEMA, **kwargs) - Layout.objects.create(oss=model, data={'operations': [], 'blocks': []}) + Layout.objects.create(oss=model, data=[]) return OperationSchema(model) @staticmethod diff --git a/rsconcept/backend/apps/oss/serializers/basics.py b/rsconcept/backend/apps/oss/serializers/basics.py index 11caab87..ec568260 100644 --- a/rsconcept/backend/apps/oss/serializers/basics.py +++ b/rsconcept/backend/apps/oss/serializers/basics.py @@ -2,16 +2,9 @@ from rest_framework import serializers -class OperationNodeSerializer(serializers.Serializer): - ''' Operation position. ''' - id = serializers.IntegerField() - x = serializers.FloatField() - y = serializers.FloatField() - - -class BlockNodeSerializer(serializers.Serializer): +class NodeSerializer(serializers.Serializer): ''' Block position. ''' - id = serializers.IntegerField() + nodeID = serializers.CharField() x = serializers.FloatField() y = serializers.FloatField() width = serializers.FloatField() @@ -19,13 +12,8 @@ class BlockNodeSerializer(serializers.Serializer): class LayoutSerializer(serializers.Serializer): - ''' Layout for OperationSchema. ''' - blocks = serializers.ListField( - child=BlockNodeSerializer() - ) - operations = serializers.ListField( - child=OperationNodeSerializer() - ) + ''' Serializer: Layout data. ''' + data = serializers.ListField(child=NodeSerializer()) # type: ignore class SubstitutionExSerializer(serializers.Serializer): diff --git a/rsconcept/backend/apps/oss/serializers/data_access.py b/rsconcept/backend/apps/oss/serializers/data_access.py index 98329c7c..9591ee05 100644 --- a/rsconcept/backend/apps/oss/serializers/data_access.py +++ b/rsconcept/backend/apps/oss/serializers/data_access.py @@ -13,7 +13,7 @@ from apps.rsform.serializers import SubstitutionSerializerBase from shared import messages as msg from ..models import Argument, Block, Inheritance, Operation, OperationSchema, OperationType -from .basics import LayoutSerializer, SubstitutionExSerializer +from .basics import NodeSerializer, SubstitutionExSerializer class OperationSerializer(serializers.ModelSerializer): @@ -52,7 +52,9 @@ class CreateBlockSerializer(serializers.Serializer): model = Block fields = 'title', 'description', 'parent' - layout = LayoutSerializer() + layout = serializers.ListField( + child=NodeSerializer() + ) item_data = BlockCreateData() width = serializers.FloatField() height = serializers.FloatField() @@ -100,7 +102,10 @@ class UpdateBlockSerializer(serializers.Serializer): model = Block fields = 'title', 'description', 'parent' - layout = LayoutSerializer(required=False) + layout = serializers.ListField( + child=NodeSerializer(), + required=False + ) target = PKField(many=False, queryset=Block.objects.all()) item_data = UpdateBlockData() @@ -127,7 +132,9 @@ class UpdateBlockSerializer(serializers.Serializer): class DeleteBlockSerializer(serializers.Serializer): ''' Serializer: Delete block. ''' - layout = LayoutSerializer() + layout = serializers.ListField( + child=NodeSerializer() + ) target = PKField(many=False, queryset=Block.objects.all().only('oss_id')) def validate(self, attrs): @@ -142,7 +149,9 @@ class DeleteBlockSerializer(serializers.Serializer): class MoveItemsSerializer(serializers.Serializer): ''' Serializer: Move items to another parent. ''' - layout = LayoutSerializer() + layout = serializers.ListField( + child=NodeSerializer() + ) operations = PKField(many=True, queryset=Operation.objects.all().only('oss_id', 'parent')) blocks = PKField(many=True, queryset=Block.objects.all().only('oss_id', 'parent')) destination = PKField(many=False, queryset=Block.objects.all().only('oss_id'), allow_null=True) @@ -196,8 +205,12 @@ class CreateOperationSerializer(serializers.Serializer): 'alias', 'operation_type', 'title', \ 'description', 'result', 'parent' - layout = LayoutSerializer() + layout = serializers.ListField( + child=NodeSerializer() + ) item_data = CreateOperationData() + width = serializers.FloatField() + height = serializers.FloatField() position_x = serializers.FloatField() position_y = serializers.FloatField() create_schema = serializers.BooleanField(default=False, required=False) @@ -230,7 +243,10 @@ class UpdateOperationSerializer(serializers.Serializer): model = Operation fields = 'alias', 'title', 'description', 'parent' - layout = LayoutSerializer(required=False) + layout = serializers.ListField( + child=NodeSerializer(), + required=False + ) target = PKField(many=False, queryset=Operation.objects.all()) item_data = UpdateOperationData() arguments = PKField(many=True, queryset=Operation.objects.all().only('oss_id', 'result_id'), required=False) @@ -297,7 +313,9 @@ class UpdateOperationSerializer(serializers.Serializer): class DeleteOperationSerializer(serializers.Serializer): ''' Serializer: Delete operation. ''' - layout = LayoutSerializer() + layout = serializers.ListField( + child=NodeSerializer() + ) target = PKField(many=False, queryset=Operation.objects.all().only('oss_id', 'result')) keep_constituents = serializers.BooleanField(default=False, required=False) delete_schema = serializers.BooleanField(default=False, required=False) @@ -314,7 +332,9 @@ class DeleteOperationSerializer(serializers.Serializer): class TargetOperationSerializer(serializers.Serializer): ''' Serializer: Target single operation. ''' - layout = LayoutSerializer() + layout = serializers.ListField( + child=NodeSerializer() + ) target = PKField(many=False, queryset=Operation.objects.all().only('oss_id', 'result_id')) def validate(self, attrs): @@ -329,7 +349,9 @@ class TargetOperationSerializer(serializers.Serializer): class SetOperationInputSerializer(serializers.Serializer): ''' Serializer: Set input schema for operation. ''' - layout = LayoutSerializer() + layout = serializers.ListField( + child=NodeSerializer() + ) target = PKField(many=False, queryset=Operation.objects.all()) input = PKField( many=False, @@ -366,7 +388,9 @@ class OperationSchemaSerializer(serializers.ModelSerializer): substitutions = serializers.ListField( child=SubstitutionExSerializer() ) - layout = LayoutSerializer() + layout = serializers.ListField( + child=NodeSerializer() + ) class Meta: ''' serializer metadata. ''' @@ -459,7 +483,7 @@ class RelocateConstituentsSerializer(serializers.Serializer): return attrs -# ====== Internals ================================================================================= +# ====== Internals ============ def _collect_descendants(start_blocks: list[Block]) -> set[int]: diff --git a/rsconcept/backend/apps/oss/tests/s_propagation/t_attributes.py b/rsconcept/backend/apps/oss/tests/s_propagation/t_attributes.py index 371f115e..df3f8525 100644 --- a/rsconcept/backend/apps/oss/tests/s_propagation/t_attributes.py +++ b/rsconcept/backend/apps/oss/tests/s_propagation/t_attributes.py @@ -59,14 +59,11 @@ class TestChangeAttributes(EndpointTester): self.operation3.refresh_from_db() self.ks3 = RSForm(self.operation3.result) - self.layout_data = { - 'operations': [ - {'id': self.operation1.pk, 'x': 0, 'y': 0}, - {'id': self.operation2.pk, 'x': 0, 'y': 0}, - {'id': self.operation3.pk, 'x': 0, 'y': 0}, - ], - 'blocks': [] - } + self.layout_data = [ + {'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, + {'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, + {'nodeID': 'o' + str(self.operation3.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, + ] layout = self.owned.layout() layout.data = self.layout_data layout.save() 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 b27283a6..84ee598c 100644 --- a/rsconcept/backend/apps/oss/tests/s_propagation/t_constituents.py +++ b/rsconcept/backend/apps/oss/tests/s_propagation/t_constituents.py @@ -57,14 +57,11 @@ class TestChangeConstituents(EndpointTester): self.ks3 = RSForm(self.operation3.result) self.assertEqual(self.ks3.constituents().count(), 4) - self.layout_data = { - 'operations': [ - {'id': self.operation1.pk, 'x': 0, 'y': 0}, - {'id': self.operation2.pk, 'x': 0, 'y': 0}, - {'id': self.operation3.pk, 'x': 0, 'y': 0}, - ], - 'blocks': [] - } + self.layout_data = [ + {'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, + {'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, + {'nodeID': 'o' + str(self.operation3.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, + ] layout = self.owned.layout() layout.data = self.layout_data layout.save() diff --git a/rsconcept/backend/apps/oss/tests/s_propagation/t_operations.py b/rsconcept/backend/apps/oss/tests/s_propagation/t_operations.py index eddad306..863fe423 100644 --- a/rsconcept/backend/apps/oss/tests/s_propagation/t_operations.py +++ b/rsconcept/backend/apps/oss/tests/s_propagation/t_operations.py @@ -107,16 +107,13 @@ class TestChangeOperations(EndpointTester): convention='KS5D4' ) - self.layout_data = { - 'operations': [ - {'id': self.operation1.pk, 'x': 0, 'y': 0}, - {'id': self.operation2.pk, 'x': 0, 'y': 0}, - {'id': self.operation3.pk, 'x': 0, 'y': 0}, - {'id': self.operation4.pk, 'x': 0, 'y': 0}, - {'id': self.operation5.pk, 'x': 0, 'y': 0}, - ], - 'blocks': [] - } + self.layout_data = [ + {'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, + {'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, + {'nodeID': 'o' + str(self.operation3.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, + {'nodeID': 'o' + str(self.operation4.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, + {'nodeID': 'o' + str(self.operation5.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40} + ] layout = self.owned.layout() layout.data = self.layout_data layout.save() diff --git a/rsconcept/backend/apps/oss/tests/s_propagation/t_substitutions.py b/rsconcept/backend/apps/oss/tests/s_propagation/t_substitutions.py index c99bfb29..ce2f22c8 100644 --- a/rsconcept/backend/apps/oss/tests/s_propagation/t_substitutions.py +++ b/rsconcept/backend/apps/oss/tests/s_propagation/t_substitutions.py @@ -107,16 +107,13 @@ class TestChangeSubstitutions(EndpointTester): convention='KS5D4' ) - self.layout_data = { - 'operations': [ - {'id': self.operation1.pk, 'x': 0, 'y': 0}, - {'id': self.operation2.pk, 'x': 0, 'y': 0}, - {'id': self.operation3.pk, 'x': 0, 'y': 0}, - {'id': self.operation4.pk, 'x': 0, 'y': 0}, - {'id': self.operation5.pk, 'x': 0, 'y': 0}, - ], - 'blocks': [] - } + self.layout_data = [ + {'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, + {'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, + {'nodeID': 'o' + str(self.operation3.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, + {'nodeID': 'o' + str(self.operation4.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, + {'nodeID': 'o' + str(self.operation5.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, + ] layout = self.owned.layout() layout.data = self.layout_data layout.save() diff --git a/rsconcept/backend/apps/oss/tests/s_views/t_blocks.py b/rsconcept/backend/apps/oss/tests/s_views/t_blocks.py index 3bc190c6..ac1c608a 100644 --- a/rsconcept/backend/apps/oss/tests/s_views/t_blocks.py +++ b/rsconcept/backend/apps/oss/tests/s_views/t_blocks.py @@ -49,16 +49,14 @@ class TestOssBlocks(EndpointTester): title='3', parent=self.block1 ) - self.layout_data = { - 'operations': [ - {'id': self.operation1.pk, 'x': 0, 'y': 0}, - {'id': self.operation2.pk, 'x': 0, 'y': 0}, - ], - 'blocks': [ - {'id': self.block1.pk, 'x': 0, 'y': 0, 'width': 0.5, 'height': 0.5}, - {'id': self.block2.pk, 'x': 0, 'y': 0, 'width': 0.5, 'height': 0.5}, - ] - } + self.layout_data = [ + {'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, + {'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, + + {'nodeID': 'b' + str(self.block1.pk), 'x': 0, 'y': 0, 'width': 0.5, 'height': 0.5}, + {'nodeID': 'b' + str(self.block2.pk), 'x': 0, 'y': 0, 'width': 0.5, 'height': 0.5}, + ] + layout = self.owned.layout() layout.data = self.layout_data layout.save() @@ -88,7 +86,7 @@ class TestOssBlocks(EndpointTester): self.assertEqual(len(response.data['oss']['blocks']), 3) new_block = response.data['new_block'] layout = response.data['oss']['layout'] - item = [item for item in layout['blocks'] if item['id'] == new_block['id']][0] + item = [item for item in layout if item['nodeID'] == 'b' + str(new_block['id'])][0] self.assertEqual(new_block['title'], data['item_data']['title']) self.assertEqual(new_block['description'], data['item_data']['description']) self.assertEqual(new_block['parent'], None) diff --git a/rsconcept/backend/apps/oss/tests/s_views/t_operations.py b/rsconcept/backend/apps/oss/tests/s_views/t_operations.py index 249f47bd..b1833f31 100644 --- a/rsconcept/backend/apps/oss/tests/s_views/t_operations.py +++ b/rsconcept/backend/apps/oss/tests/s_views/t_operations.py @@ -54,14 +54,11 @@ class TestOssOperations(EndpointTester): alias='3', operation_type=OperationType.SYNTHESIS ) - self.layout_data = { - 'operations': [ - {'id': self.operation1.pk, 'x': 0, 'y': 0}, - {'id': self.operation2.pk, 'x': 0, 'y': 0}, - {'id': self.operation3.pk, 'x': 0, 'y': 0}, - ], - 'blocks': [] - } + self.layout_data = [ + {'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, + {'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, + {'nodeID': 'o' + str(self.operation3.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, + ] layout = self.owned.layout() layout.data = self.layout_data layout.save() @@ -87,7 +84,9 @@ class TestOssOperations(EndpointTester): }, 'layout': self.layout_data, 'position_x': 1, - 'position_y': 1 + 'position_y': 1, + 'width': 500, + 'height': 50 } self.executeBadData(data=data) @@ -102,7 +101,7 @@ class TestOssOperations(EndpointTester): self.assertEqual(len(response.data['oss']['operations']), 4) new_operation = response.data['new_operation'] layout = response.data['oss']['layout'] - item = [item for item in layout['operations'] if item['id'] == new_operation['id']][0] + item = [item for item in layout if item['nodeID'] == 'o' + str(new_operation['id'])][0] self.assertEqual(new_operation['alias'], data['item_data']['alias']) self.assertEqual(new_operation['operation_type'], data['item_data']['operation_type']) self.assertEqual(new_operation['title'], data['item_data']['title']) @@ -111,6 +110,8 @@ class TestOssOperations(EndpointTester): self.assertEqual(new_operation['parent'], None) self.assertEqual(item['x'], data['position_x']) self.assertEqual(item['y'], data['position_y']) + self.assertEqual(item['width'], data['width']) + self.assertEqual(item['height'], data['height']) self.operation1.refresh_from_db() self.executeForbidden(data=data, item=self.unowned_id) @@ -132,7 +133,9 @@ class TestOssOperations(EndpointTester): }, 'layout': self.layout_data, 'position_x': 1, - 'position_y': 1 + 'position_y': 1, + 'width': 500, + 'height': 50 } self.executeBadData(data=data, item=self.owned_id) @@ -160,6 +163,8 @@ class TestOssOperations(EndpointTester): 'layout': self.layout_data, 'position_x': 1, 'position_y': 1, + 'width': 500, + 'height': 50, 'arguments': [self.operation1.pk, self.operation3.pk] } response = self.executeCreated(data=data, item=self.owned_id) @@ -185,7 +190,9 @@ class TestOssOperations(EndpointTester): }, 'layout': self.layout_data, 'position_x': 1, - 'position_y': 1 + 'position_y': 1, + 'width': 500, + 'height': 50 } response = self.executeCreated(data=data, item=self.owned_id) new_operation = response.data['new_operation'] @@ -207,7 +214,9 @@ class TestOssOperations(EndpointTester): 'create_schema': True, 'layout': self.layout_data, 'position_x': 1, - 'position_y': 1 + 'position_y': 1, + 'width': 500, + 'height': 50 } self.executeBadData(data=data, item=self.owned_id) data['item_data']['result'] = None @@ -244,7 +253,7 @@ class TestOssOperations(EndpointTester): self.login() response = self.executeOK(data=data) layout = response.data['layout'] - deleted_items = [item for item in layout['operations'] if item['id'] == data['target']] + deleted_items = [item for item in layout if item['nodeID'] == 'o' + str(data['target'])] self.assertEqual(len(response.data['operations']), 2) self.assertEqual(len(deleted_items), 0) 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 8e55f271..969b23ff 100644 --- a/rsconcept/backend/apps/oss/tests/s_views/t_oss.py +++ b/rsconcept/backend/apps/oss/tests/s_views/t_oss.py @@ -55,11 +55,11 @@ class TestOssViewset(EndpointTester): alias='3', operation_type=OperationType.SYNTHESIS ) - self.layout_data = {'operations': [ - {'id': self.operation1.pk, 'x': 0, 'y': 0}, - {'id': self.operation2.pk, 'x': 0, 'y': 0}, - {'id': self.operation3.pk, 'x': 0, 'y': 0}, - ], 'blocks': []} + self.layout_data = [ + {'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, + {'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, + {'nodeID': 'o' + str(self.operation3.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40} + ] layout = self.owned.layout() layout.data = self.layout_data layout.save() @@ -107,10 +107,9 @@ class TestOssViewset(EndpointTester): self.assertEqual(arguments[1]['argument'], self.operation2.pk) layout = response.data['layout'] - self.assertEqual(layout['blocks'], []) - self.assertEqual(layout['operations'][0], {'id': self.operation1.pk, 'x': 0, 'y': 0}) - self.assertEqual(layout['operations'][1], {'id': self.operation2.pk, 'x': 0, 'y': 0}) - self.assertEqual(layout['operations'][2], {'id': self.operation3.pk, 'x': 0, 'y': 0}) + self.assertEqual(layout[0], self.layout_data[0]) + self.assertEqual(layout[1], self.layout_data[1]) + self.assertEqual(layout[2], self.layout_data[2]) self.executeOK(item=self.unowned_id) self.executeForbidden(item=self.private_id) @@ -126,23 +125,21 @@ class TestOssViewset(EndpointTester): self.populateData() self.executeBadData(item=self.owned_id) - data = {'operations': [], 'blocks': []} + data = {'data': []} self.executeOK(data=data) - data = { - 'operations': [ - {'id': self.operation1.pk, 'x': 42.1, 'y': 1337}, - {'id': self.operation2.pk, 'x': 36.1, 'y': 1437}, - {'id': self.operation3.pk, 'x': 36.1, 'y': 1435} - ], 'blocks': [] - } + data = {'data': [ + {'nodeID': 'o' + str(self.operation1.pk), 'x': 42.1, 'y': 1337, 'width': 150, 'height': 40}, + {'nodeID': 'o' + str(self.operation2.pk), 'x': 36.1, 'y': 1437, 'width': 150, 'height': 40}, + {'nodeID': 'o' + str(self.operation3.pk), 'x': 36.1, 'y': 1435, 'width': 150, 'height': 40} + ]} self.toggle_admin(True) self.executeOK(data=data, item=self.unowned_id) self.toggle_admin(False) self.executeOK(data=data, item=self.owned_id) self.owned.refresh_from_db() - self.assertEqual(self.owned.layout().data, data) + self.assertEqual(self.owned.layout().data, data['data']) self.executeForbidden(data=data, item=self.unowned_id) self.executeForbidden(data=data, item=self.private_id) diff --git a/rsconcept/backend/apps/oss/views/oss.py b/rsconcept/backend/apps/oss/views/oss.py index e99d2746..07cb7071 100644 --- a/rsconcept/backend/apps/oss/views/oss.py +++ b/rsconcept/backend/apps/oss/views/oss.py @@ -91,7 +91,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev ''' Endpoint: Update schema layout. ''' serializer = s.LayoutSerializer(data=request.data) serializer.is_valid(raise_exception=True) - m.OperationSchema(self.get_object()).update_layout(serializer.validated_data) + m.OperationSchema(self.get_object()).update_layout(serializer.validated_data['data']) return Response(status=c.HTTP_200_OK) @extend_schema( @@ -120,8 +120,8 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev children_operations: list[m.Operation] = serializer.validated_data['children_operations'] with transaction.atomic(): new_block = oss.create_block(**serializer.validated_data['item_data']) - layout['blocks'].append({ - 'id': new_block.pk, + layout.append({ + 'nodeID': 'b' + str(new_block.pk), 'x': serializer.validated_data['position_x'], 'y': serializer.validated_data['position_y'], 'width': serializer.validated_data['width'], @@ -205,7 +205,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev oss = m.OperationSchema(self.get_object()) block = cast(m.Block, serializer.validated_data['target']) layout = serializer.validated_data['layout'] - layout['blocks'] = [x for x in layout['blocks'] if x['id'] != block.pk] + layout = [x for x in layout if x['nodeID'] != 'b' + str(block.pk)] with transaction.atomic(): oss.delete_block(block) oss.update_layout(layout) @@ -274,10 +274,12 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev layout = serializer.validated_data['layout'] with transaction.atomic(): new_operation = oss.create_operation(**serializer.validated_data['item_data']) - layout['operations'].append({ - 'id': new_operation.pk, + layout.append({ + 'nodeID': 'o' + str(new_operation.pk), 'x': serializer.validated_data['position_x'], - 'y': serializer.validated_data['position_y'] + 'y': serializer.validated_data['position_y'], + 'width': serializer.validated_data['width'], + 'height': serializer.validated_data['height'] }) oss.update_layout(layout) @@ -384,7 +386,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev operation = cast(m.Operation, serializer.validated_data['target']) old_schema = operation.result layout = serializer.validated_data['layout'] - layout['operations'] = [x for x in layout['operations'] if x['id'] != operation.pk] + layout = [x for x in layout if x['nodeID'] != 'o' + str(operation.pk)] with transaction.atomic(): oss.delete_operation(operation.pk, serializer.validated_data['keep_constituents']) oss.update_layout(layout) diff --git a/rsconcept/frontend/src/features/oss/backend/api.ts b/rsconcept/frontend/src/features/oss/backend/api.ts index dcf08b8b..c27eede9 100644 --- a/rsconcept/frontend/src/features/oss/backend/api.ts +++ b/rsconcept/frontend/src/features/oss/backend/api.ts @@ -50,7 +50,7 @@ export const ossApi = { axiosPatch({ endpoint: `/api/oss/${itemID}/update-layout`, request: { - data: data, + data: { data: data }, successMessage: isSilent ? undefined : infoMsg.changesSaved } }), diff --git a/rsconcept/frontend/src/features/oss/backend/oss-loader.ts b/rsconcept/frontend/src/features/oss/backend/oss-loader.ts index 3260c1a9..d9f14b4a 100644 --- a/rsconcept/frontend/src/features/oss/backend/oss-loader.ts +++ b/rsconcept/frontend/src/features/oss/backend/oss-loader.ts @@ -90,7 +90,7 @@ export class OssLoader { this.graph.topologicalOrder().forEach(operationID => { const operation = this.operationByID.get(operationID)!; const schema = this.items.find(item => item.id === operation.result); - const position = this.oss.layout.operations.find(item => item.id === operationID); + const position = this.oss.layout.find(item => item.nodeID === operation.nodeID); operation.x = position?.x ?? 0; operation.y = position?.y ?? 0; operation.is_consolidation = this.inferConsolidation(operationID); @@ -104,7 +104,7 @@ export class OssLoader { private inferBlockAttributes() { this.oss.blocks.forEach(block => { - const geometry = this.oss.layout.blocks.find(item => item.id === block.id); + const geometry = this.oss.layout.find(item => item.nodeID === block.nodeID); block.x = geometry?.x ?? 0; block.y = geometry?.y ?? 0; block.width = geometry?.width ?? BLOCK_NODE_MIN_WIDTH; diff --git a/rsconcept/frontend/src/features/oss/backend/types.ts b/rsconcept/frontend/src/features/oss/backend/types.ts index 84e78a49..09ed7a9e 100644 --- a/rsconcept/frontend/src/features/oss/backend/types.ts +++ b/rsconcept/frontend/src/features/oss/backend/types.ts @@ -72,11 +72,8 @@ export type IRelocateConstituentsDTO = z.infer; -/** Represents {@link IOperation} position. */ -export type IOperationPosition = z.infer; - -/** Represents {@link IBlock} position. */ -export type IBlockPosition = z.infer; +/** Represents {@link IOperationSchema} node position. */ +export type INodePosition = z.infer; // ====== Schemas ====== export const schemaOperationType = z.enum(Object.values(OperationType) as [OperationType, ...OperationType[]]); @@ -108,24 +105,15 @@ export const schemaCstSubstituteInfo = schemaSubstituteConstituents.extend({ substitution_term: z.string() }); -export const schemaOperationPosition = z.strictObject({ - id: z.number(), - x: z.number(), - y: z.number() -}); - -export const schemaBlockPosition = z.strictObject({ - id: z.number(), +export const schemaNodePosition = z.strictObject({ + nodeID: z.string(), x: z.number(), y: z.number(), width: z.number(), height: z.number() }); -export const schemaOssLayout = z.strictObject({ - operations: z.array(schemaOperationPosition), - blocks: z.array(schemaBlockPosition) -}); +export const schemaOssLayout = z.array(schemaNodePosition); export const schemaOperationSchema = schemaLibraryItem.extend({ editors: z.number().array(), @@ -188,6 +176,8 @@ export const schemaCreateOperation = z.strictObject({ }), position_x: z.number(), position_y: z.number(), + width: z.number(), + height: z.number(), arguments: z.array(z.number()), create_schema: z.boolean() }); diff --git a/rsconcept/frontend/src/features/oss/dialogs/dlg-create-operation/dlg-create-operation.tsx b/rsconcept/frontend/src/features/oss/dialogs/dlg-create-operation/dlg-create-operation.tsx index 561f8d6e..6cf89754 100644 --- a/rsconcept/frontend/src/features/oss/dialogs/dlg-create-operation/dlg-create-operation.tsx +++ b/rsconcept/frontend/src/features/oss/dialogs/dlg-create-operation/dlg-create-operation.tsx @@ -13,7 +13,7 @@ import { useDialogsStore } from '@/stores/dialogs'; import { type ICreateOperationDTO, OperationType, schemaCreateOperation } from '../../backend/types'; import { useCreateOperation } from '../../backend/use-create-operation'; import { describeOperationType, labelOperationType } from '../../labels'; -import { type LayoutManager } from '../../models/oss-layout-api'; +import { type LayoutManager, OPERATION_NODE_HEIGHT, OPERATION_NODE_WIDTH } from '../../models/oss-layout-api'; import { TabInputOperation } from './tab-input-operation'; import { TabSynthesisOperation } from './tab-synthesis-operation'; @@ -54,6 +54,8 @@ export function DlgCreateOperation() { position_x: defaultX, position_y: defaultY, arguments: initialInputs, + width: OPERATION_NODE_WIDTH, + height: OPERATION_NODE_HEIGHT, create_schema: false, layout: manager.layout }, diff --git a/rsconcept/frontend/src/features/oss/models/oss-layout-api.ts b/rsconcept/frontend/src/features/oss/models/oss-layout-api.ts index 74b3cfdf..b560860b 100644 --- a/rsconcept/frontend/src/features/oss/models/oss-layout-api.ts +++ b/rsconcept/frontend/src/features/oss/models/oss-layout-api.ts @@ -1,10 +1,4 @@ -import { - type IBlockPosition, - type ICreateBlockDTO, - type ICreateOperationDTO, - type IOperationPosition, - type IOssLayout -} from '../backend/types'; +import { type ICreateBlockDTO, type ICreateOperationDTO, type INodePosition, type IOssLayout } from '../backend/types'; import { type IOperationSchema } from './oss'; import { type Position2D, type Rectangle2D } from './oss-layout'; @@ -12,8 +6,8 @@ import { type Position2D, type Rectangle2D } from './oss-layout'; export const GRID_SIZE = 10; // pixels - size of OSS grid const MIN_DISTANCE = 2 * GRID_SIZE; // pixels - minimum distance between nodes -const OPERATION_NODE_WIDTH = 150; -const OPERATION_NODE_HEIGHT = 40; +export const OPERATION_NODE_WIDTH = 150; +export const OPERATION_NODE_HEIGHT = 40; /** Layout manipulations for {@link IOperationSchema}. */ export class LayoutManager { @@ -30,27 +24,30 @@ export class LayoutManager { } /** Calculate insert position for a new {@link IOperation} */ - newOperationPosition(data: ICreateOperationDTO): Position2D { - let result = { x: data.position_x, y: data.position_y }; - const operations = this.layout.operations; - const parentNode = this.layout.blocks.find(pos => pos.id === data.item_data.parent); - if (operations.length === 0) { + newOperationPosition(data: ICreateOperationDTO): Rectangle2D { + let result = { x: data.position_x, y: data.position_y, width: data.width, height: data.height }; + const parentNode = this.layout.find(pos => pos.nodeID === `b${data.item_data.parent}`); + if (this.oss.operations.length === 0) { return result; } + const operations = this.layout.filter(pos => pos.nodeID.startsWith('o')); if (data.arguments.length !== 0) { - result = calculatePositionFromArgs(data.arguments, operations); + const pos = calculatePositionFromArgs( + operations.filter(node => data.arguments.includes(Number(node.nodeID.slice(1)))) + ); + result.x = pos.x; + result.y = pos.y; } else if (parentNode) { result.x = parentNode.x + MIN_DISTANCE; result.y = parentNode.y + MIN_DISTANCE; } else { - result = this.calculatePositionForFreeOperation(result); + const pos = this.calculatePositionForFreeOperation(result); + result.x = pos.x; + result.y = pos.y; } - result = preventOverlap( - { ...result, width: OPERATION_NODE_WIDTH, height: OPERATION_NODE_HEIGHT }, - operations.map(node => ({ ...node, width: OPERATION_NODE_WIDTH, height: OPERATION_NODE_HEIGHT })) - ); + result = preventOverlap(result, operations); if (parentNode) { const borderX = result.x + OPERATION_NODE_WIDTH + MIN_DISTANCE; @@ -64,18 +61,18 @@ export class LayoutManager { // TODO: trigger cascading updates } - return { x: result.x, y: result.y }; + return result; } /** Calculate insert position for a new {@link IBlock} */ newBlockPosition(data: ICreateBlockDTO): Rectangle2D { const block_nodes = data.children_blocks - .map(id => this.layout.blocks.find(block => block.id === id)) + .map(id => this.layout.find(block => block.nodeID === `b${id}`)) .filter(node => !!node); const operation_nodes = data.children_operations - .map(id => this.layout.operations.find(operation => operation.id === id)) + .map(id => this.layout.find(operation => operation.nodeID === `o${id}`)) .filter(node => !!node); - const parentNode = this.layout.blocks.find(pos => pos.id === data.item_data.parent); + const parentNode = this.layout.find(pos => pos.nodeID === `b${data.item_data.parent}`); let result: Rectangle2D = { x: data.position_x, y: data.position_y, width: data.width, height: data.height }; @@ -98,19 +95,21 @@ export class LayoutManager { if (block_nodes.length === 0 && operation_nodes.length === 0) { if (parentNode) { - const siblings = this.oss.blocks.filter(block => block.parent === parentNode.id).map(block => block.id); + const siblings = this.oss.blocks + .filter(block => block.parent === data.item_data.parent) + .map(block => block.nodeID); if (siblings.length > 0) { result = preventOverlap( result, - this.layout.blocks.filter(block => siblings.includes(block.id)) + this.layout.filter(node => siblings.includes(node.nodeID)) ); } } else { - const rootBlocks = this.oss.blocks.filter(block => block.parent === null).map(block => block.id); + const rootBlocks = this.oss.blocks.filter(block => block.parent === null).map(block => block.nodeID); if (rootBlocks.length > 0) { result = preventOverlap( result, - this.layout.blocks.filter(block => rootBlocks.includes(block.id)) + this.layout.filter(node => rootBlocks.includes(node.nodeID)) ); } } @@ -133,7 +132,34 @@ export class LayoutManager { /** Update layout when parent changes */ onOperationChangeParent(targetID: number, newParent: number | null) { - console.error('not implemented', targetID, newParent); + const targetNode = this.layout.find(pos => pos.nodeID === `o${targetID}`); + if (!targetNode) { + return; + } + + if (newParent === null) { + const rootBlocks = this.oss.blocks.filter(block => block.parent === null).map(block => block.nodeID); + const blocksPositions = this.layout.filter(pos => rootBlocks.includes(pos.nodeID)); + if (blocksPositions.length === 0) { + return; + } + + const operationPositions = this.layout.filter(pos => pos.nodeID.startsWith('o') && pos.nodeID !== `o${targetID}`); + const newRect = preventOverlap(targetNode, [...blocksPositions, ...operationPositions]); + targetNode.x = newRect.x; + targetNode.y = newRect.y; + return; + } else { + const parentNode = this.layout.find(pos => pos.nodeID === `b${newParent}`); + if (!parentNode) { + return; + } + if (rectanglesOverlap(parentNode, targetNode)) { + return; + } + + // TODO: fix position based on parent + } } /** Update layout when parent changes */ @@ -142,17 +168,16 @@ export class LayoutManager { } private calculatePositionForFreeOperation(initial: Position2D): Position2D { - const operations = this.layout.operations; - if (operations.length === 0) { + if (this.oss.operations.length === 0) { return initial; } const freeInputs = this.oss.operations .filter(operation => operation.arguments.length === 0 && operation.parent === null) - .map(operation => operation.id); - let inputsPositions = operations.filter(pos => freeInputs.includes(pos.id)); + .map(operation => operation.nodeID); + let inputsPositions = this.layout.filter(pos => freeInputs.includes(pos.nodeID)); if (inputsPositions.length === 0) { - inputsPositions = operations; + inputsPositions = this.layout.filter(pos => pos.nodeID.startsWith('o')); } const maxX = Math.max(...inputsPositions.map(node => node.x)); const minY = Math.min(...inputsPositions.map(node => node.y)); @@ -163,8 +188,8 @@ export class LayoutManager { } private calculatePositionForFreeBlock(initial: Rectangle2D): Rectangle2D { - const rootBlocks = this.oss.blocks.filter(block => block.parent === null).map(block => block.id); - const blocksPositions = this.layout.blocks.filter(pos => rootBlocks.includes(pos.id)); + const rootBlocks = this.oss.blocks.filter(block => block.parent === null).map(block => block.nodeID); + const blocksPositions = this.layout.filter(pos => rootBlocks.includes(pos.nodeID)); if (blocksPositions.length === 0) { return initial; } @@ -211,11 +236,10 @@ function preventOverlap(target: Rectangle2D, fixedRectangles: Rectangle2D[]): Re return target; } -function calculatePositionFromArgs(args: number[], operations: IOperationPosition[]): Position2D { - const argNodes = operations.filter(pos => args.includes(pos.id)); - const maxY = Math.max(...argNodes.map(node => node.y)); - const minX = Math.min(...argNodes.map(node => node.x)); - const maxX = Math.max(...argNodes.map(node => node.x)); +function calculatePositionFromArgs(args: INodePosition[]): Position2D { + const maxY = Math.max(...args.map(node => node.y)); + const minX = Math.min(...args.map(node => node.x)); + const maxX = Math.max(...args.map(node => node.x)); return { x: Math.ceil((maxX + minX) / 2 / GRID_SIZE) * GRID_SIZE, y: maxY + 2 * OPERATION_NODE_HEIGHT + MIN_DISTANCE @@ -224,8 +248,8 @@ function calculatePositionFromArgs(args: number[], operations: IOperationPositio function calculatePositionFromChildren( initial: Rectangle2D, - operations: IOperationPosition[], - blocks: IBlockPosition[] + operations: INodePosition[], + blocks: INodePosition[] ): Rectangle2D { let left = undefined; let top = undefined; @@ -249,11 +273,11 @@ function calculatePositionFromChildren( top = top === undefined ? operation.y - MIN_DISTANCE : Math.min(top, operation.y - MIN_DISTANCE); right = right === undefined - ? Math.max(left + initial.width, operation.x + OPERATION_NODE_WIDTH + MIN_DISTANCE) - : Math.max(right, operation.x + OPERATION_NODE_WIDTH + MIN_DISTANCE); + ? Math.max(left + initial.width, operation.x + operation.width + MIN_DISTANCE) + : Math.max(right, operation.x + operation.width + MIN_DISTANCE); bottom = !bottom - ? Math.max(top + initial.height, operation.y + OPERATION_NODE_HEIGHT + MIN_DISTANCE) - : Math.max(bottom, operation.y + OPERATION_NODE_HEIGHT + MIN_DISTANCE); + ? Math.max(top + initial.height, operation.y + operation.height + MIN_DISTANCE) + : Math.max(bottom, operation.y + operation.height + MIN_DISTANCE); } return { diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/use-get-layout.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/use-get-layout.tsx index 87b23bec..02624e70 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/use-get-layout.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/use-get-layout.tsx @@ -3,6 +3,7 @@ import { type Node, useReactFlow } from 'reactflow'; import { type IOssLayout } from '../../../backend/types'; import { type IOperationSchema } from '../../../models/oss'; import { type Position2D } from '../../../models/oss-layout'; +import { OPERATION_NODE_HEIGHT, OPERATION_NODE_WIDTH } from '../../../models/oss-layout-api'; import { useOssEdit } from '../oss-edit-context'; import { BLOCK_NODE_MIN_HEIGHT, BLOCK_NODE_MIN_WIDTH } from './graph/block-node'; @@ -14,22 +15,24 @@ export function useGetLayout() { return function getLayout(): IOssLayout { const nodes = getNodes(); const nodeById = new Map(nodes.map(node => [node.id, node])); - return { - operations: nodes + return [ + ...nodes .filter(node => node.type !== 'block') .map(node => ({ - id: schema.itemByNodeID.get(node.id)!.id, - ...computeAbsolutePosition(node, schema, nodeById) + nodeID: node.id, + ...computeAbsolutePosition(node, schema, nodeById), + width: OPERATION_NODE_WIDTH, + height: OPERATION_NODE_HEIGHT })), - blocks: nodes + ...nodes .filter(node => node.type === 'block') .map(node => ({ - id: schema.itemByNodeID.get(node.id)!.id, + nodeID: node.id, ...computeAbsolutePosition(node, schema, nodeById), width: node.width ?? BLOCK_NODE_MIN_WIDTH, height: node.height ?? BLOCK_NODE_MIN_HEIGHT })) - }; + ]; }; }