From 3271d9244c54a3195a2d4ed77baba34c4c2f0232 Mon Sep 17 00:00:00 2001 From: Ivan <8611739+IRBorisov@users.noreply.github.com> Date: Sun, 6 Apr 2025 13:28:00 +0300 Subject: [PATCH] R: Restructuring layout data pt1 --- .../apps/library/tests/s_views/t_library.py | 5 + .../backend/apps/library/views/library.py | 4 +- rsconcept/backend/apps/oss/admin.py | 19 +- ...11_remove_operation_position_x_and_more.py | 84 ++++ rsconcept/backend/apps/oss/models/Block.py | 39 ++ rsconcept/backend/apps/oss/models/Layout.py | 25 + .../backend/apps/oss/models/Operation.py | 20 +- .../apps/oss/models/OperationSchema.py | 20 +- rsconcept/backend/apps/oss/models/__init__.py | 2 + .../backend/apps/oss/serializers/__init__.py | 2 +- .../backend/apps/oss/serializers/basics.py | 26 +- .../apps/oss/serializers/data_access.py | 38 +- .../apps/oss/tests/s_models/t_Operation.py | 3 +- .../oss/tests/s_propagation/t_attributes.py | 14 +- .../oss/tests/s_propagation/t_constituents.py | 12 + .../oss/tests/s_propagation/t_operations.py | 32 +- .../tests/s_propagation/t_substitutions.py | 24 +- .../apps/oss/tests/s_views/__init__.py | 1 + .../apps/oss/tests/s_views/t_operations.py | 447 ++++++++++++++++++ .../backend/apps/oss/tests/s_views/t_oss.py | 409 +--------------- rsconcept/backend/apps/oss/views/oss.py | 37 +- .../frontend/src/features/oss/backend/api.ts | 4 +- ...te-positions.tsx => use-update-layout.tsx} | 8 +- .../oss/dialogs/dlg-relocate-constituents.tsx | 4 +- .../oss-page/editor-oss-graph/oss-flow.tsx | 4 +- .../editor-oss-graph/toolbar-oss-graph.tsx | 4 +- 26 files changed, 808 insertions(+), 479 deletions(-) create mode 100644 rsconcept/backend/apps/oss/migrations/0011_remove_operation_position_x_and_more.py create mode 100644 rsconcept/backend/apps/oss/models/Block.py create mode 100644 rsconcept/backend/apps/oss/models/Layout.py create mode 100644 rsconcept/backend/apps/oss/tests/s_views/t_operations.py rename rsconcept/frontend/src/features/oss/backend/{use-update-positions.tsx => use-update-layout.tsx} (85%) 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 9cff6e94..ac1cd0be 100644 --- a/rsconcept/backend/apps/library/tests/s_views/t_library.py +++ b/rsconcept/backend/apps/library/tests/s_views/t_library.py @@ -9,6 +9,7 @@ from apps.library.models import ( LibraryTemplate, LocationHead ) +from apps.oss.models import OperationSchema from apps.rsform.models import RSForm from shared.EndpointTester import EndpointTester, decl_endpoint from shared.testing_utils import response_contains @@ -58,6 +59,8 @@ class TestLibraryViewset(EndpointTester): 'read_only': True } response = self.executeCreated(data=data) + oss = OperationSchema(LibraryItem.objects.get(pk=response.data['id'])) + self.assertEqual(oss.model.owner, self.user) self.assertEqual(response.data['owner'], self.user.pk) self.assertEqual(response.data['item_type'], data['item_type']) self.assertEqual(response.data['title'], data['title']) @@ -65,6 +68,8 @@ 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 4bd0996e..95d36064 100644 --- a/rsconcept/backend/apps/library/views/library.py +++ b/rsconcept/backend/apps/library/views/library.py @@ -13,7 +13,7 @@ from rest_framework.decorators import action from rest_framework.request import Request from rest_framework.response import Response -from apps.oss.models import Operation, OperationSchema, PropagationFacade +from apps.oss.models import Layout, Operation, OperationSchema, PropagationFacade from apps.rsform.models import RSForm from apps.rsform.serializers import RSFormParseSerializer from apps.users.models import User @@ -40,6 +40,8 @@ class LibraryViewSet(viewsets.ModelViewSet): serializer.save(owner=self.request.user) else: serializer.save() + if serializer.data.get('item_type') == m.LibraryItemType.OPERATION_SCHEMA: + Layout.objects.create(oss=serializer.instance, data={'operations': [], 'blocks': []}) def perform_update(self, serializer) -> None: instance = serializer.save() diff --git a/rsconcept/backend/apps/oss/admin.py b/rsconcept/backend/apps/oss/admin.py index 86f87aab..c88e118b 100644 --- a/rsconcept/backend/apps/oss/admin.py +++ b/rsconcept/backend/apps/oss/admin.py @@ -15,11 +15,24 @@ class OperationAdmin(admin.ModelAdmin): 'alias', 'title', 'description', - 'position_x', - 'position_y'] + 'parent'] search_fields = ['id', 'operation_type', 'title', 'alias'] +class BlockAdmin(admin.ModelAdmin): + ''' Admin model: Block. ''' + ordering = ['oss'] + list_display = ['id', 'oss', 'title', 'description', 'parent'] + search_fields = ['oss'] + + +class LayoutAdmin(admin.ModelAdmin): + ''' Admin model: Layout. ''' + ordering = ['oss'] + list_display = ['id', 'oss', 'data'] + search_fields = ['oss'] + + class ArgumentAdmin(admin.ModelAdmin): ''' Admin model: Operation arguments. ''' ordering = ['operation'] @@ -42,6 +55,8 @@ class InheritanceAdmin(admin.ModelAdmin): admin.site.register(models.Operation, OperationAdmin) +admin.site.register(models.Block, BlockAdmin) +admin.site.register(models.Layout, LayoutAdmin) admin.site.register(models.Argument, ArgumentAdmin) admin.site.register(models.Substitution, SynthesisSubstitutionAdmin) admin.site.register(models.Inheritance, InheritanceAdmin) diff --git a/rsconcept/backend/apps/oss/migrations/0011_remove_operation_position_x_and_more.py b/rsconcept/backend/apps/oss/migrations/0011_remove_operation_position_x_and_more.py new file mode 100644 index 00000000..31ff26ba --- /dev/null +++ b/rsconcept/backend/apps/oss/migrations/0011_remove_operation_position_x_and_more.py @@ -0,0 +1,84 @@ +# Generated by Django 5.1.7 on 2025-03-26 16:04 + +import django.db.models.deletion +from django.db import migrations, models + + +def migrate_layout(apps, schema_editor): + LibraryItem = apps.get_model('library', 'LibraryItem') + Operation = apps.get_model('oss', 'Operation') + Layout = apps.get_model('oss', 'Layout') + + for library_item in LibraryItem.objects.filter(item_type='oss'): + layout_data = {'operations': [], 'blocks': []} + + operations = Operation.objects.filter(oss=library_item) + for operation in operations: + layout_data['operations'].append({ + 'id': operation.id, + 'x': operation.position_x, + 'y': operation.position_y + }) + + Layout.objects.create(oss=library_item, data=layout_data) + + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0007_rename_libraryitem_comment_libraryitem_description'), + ('oss', '0010_rename_comment_operation_description'), + ] + + operations = [ + migrations.CreateModel( + name='Layout', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('data', models.JSONField(default=dict, verbose_name='Расположение')), + ('oss', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, + related_name='layout', to='library.libraryitem', verbose_name='Схема синтеза')), + ], + options={ + 'verbose_name': 'Схема расположения', + 'verbose_name_plural': 'Схемы расположения', + }, + ), + migrations.RunPython(migrate_layout), + migrations.RemoveField( + model_name='operation', + name='position_x', + ), + migrations.RemoveField( + model_name='operation', + name='position_y', + ), + + migrations.CreateModel( + name='Block', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.TextField(blank=True, verbose_name='Название')), + ('description', models.TextField(blank=True, verbose_name='Описание')), + ('oss', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, + related_name='blocks', to='library.libraryitem', verbose_name='Схема синтеза')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name='as_child_block', to='oss.block', verbose_name='Содержащий блок')), + ], + options={ + 'verbose_name': 'Блок', + 'verbose_name_plural': 'Блоки', + }, + ), + migrations.AddField( + model_name='operation', + name='parent', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='as_child_operation', + to='oss.block', + verbose_name='Содержащий блок'), + ), + ] diff --git a/rsconcept/backend/apps/oss/models/Block.py b/rsconcept/backend/apps/oss/models/Block.py new file mode 100644 index 00000000..12780d6d --- /dev/null +++ b/rsconcept/backend/apps/oss/models/Block.py @@ -0,0 +1,39 @@ +''' Models: Content Block in OSS. ''' +# pylint: disable=duplicate-code +from django.db.models import CASCADE, SET_NULL, ForeignKey, Model, TextField + + +class Block(Model): + ''' Block of content in OSS.''' + oss = ForeignKey( + verbose_name='Схема синтеза', + to='library.LibraryItem', + on_delete=CASCADE, + related_name='blocks' + ) + + title = TextField( + verbose_name='Название', + blank=True + ) + description = TextField( + verbose_name='Описание', + blank=True + ) + + parent = ForeignKey( + verbose_name='Содержащий блок', + to='oss.Block', + blank=True, + null=True, + on_delete=SET_NULL, + related_name='as_child_block' + ) + + class Meta: + ''' Model metadata. ''' + verbose_name = 'Блок' + verbose_name_plural = 'Блоки' + + def __str__(self) -> str: + return f'Блок {self.title}' diff --git a/rsconcept/backend/apps/oss/models/Layout.py b/rsconcept/backend/apps/oss/models/Layout.py new file mode 100644 index 00000000..1f6efdd1 --- /dev/null +++ b/rsconcept/backend/apps/oss/models/Layout.py @@ -0,0 +1,25 @@ +''' Models: Content Block in OSS. ''' +from django.db.models import CASCADE, ForeignKey, JSONField, Model + + +class Layout(Model): + ''' Node layout in OSS.''' + oss = ForeignKey( + verbose_name='Схема синтеза', + to='library.LibraryItem', + on_delete=CASCADE, + related_name='layout' + ) + + data = JSONField( + verbose_name='Расположение', + default=dict + ) + + class Meta: + ''' Model metadata. ''' + verbose_name = 'Схема расположения' + verbose_name_plural = 'Схемы расположения' + + def __str__(self) -> str: + return f'Схема расположения {self.oss.alias}' diff --git a/rsconcept/backend/apps/oss/models/Operation.py b/rsconcept/backend/apps/oss/models/Operation.py index 0c0a3e8b..ffc1e4fa 100644 --- a/rsconcept/backend/apps/oss/models/Operation.py +++ b/rsconcept/backend/apps/oss/models/Operation.py @@ -1,9 +1,9 @@ ''' Models: Operation in OSS. ''' +# pylint: disable=duplicate-code from django.db.models import ( CASCADE, SET_NULL, CharField, - FloatField, ForeignKey, Model, QuerySet, @@ -44,6 +44,15 @@ class Operation(Model): related_name='producer' ) + parent = ForeignKey( + verbose_name='Содержащий блок', + to='oss.Block', + blank=True, + null=True, + on_delete=SET_NULL, + related_name='as_child_operation' + ) + alias = CharField( verbose_name='Шифр', max_length=255, @@ -58,15 +67,6 @@ class Operation(Model): blank=True ) - position_x = FloatField( - verbose_name='Положение по горизонтали', - default=0 - ) - position_y = FloatField( - verbose_name='Положение по вертикали', - default=0 - ) - class Meta: ''' Model metadata. ''' verbose_name = 'Операция' diff --git a/rsconcept/backend/apps/oss/models/OperationSchema.py b/rsconcept/backend/apps/oss/models/OperationSchema.py index 6cabf745..5796c4b3 100644 --- a/rsconcept/backend/apps/oss/models/OperationSchema.py +++ b/rsconcept/backend/apps/oss/models/OperationSchema.py @@ -20,6 +20,7 @@ from apps.rsform.models import ( from .Argument import Argument from .Inheritance import Inheritance +from .Layout import Layout from .Operation import Operation from .Substitution import Substitution @@ -38,6 +39,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': []}) return OperationSchema(model) @staticmethod @@ -62,6 +64,12 @@ class OperationSchema: ''' Operation arguments. ''' return Argument.objects.filter(operation__oss=self.model) + def layout(self) -> Layout: + ''' OSS layout. ''' + result = Layout.objects.filter(oss=self.model).first() + assert result is not None + return result + def substitutions(self) -> QuerySet[Substitution]: ''' Operation substitutions. ''' return Substitution.objects.filter(operation__oss=self.model) @@ -78,15 +86,11 @@ class OperationSchema: location=self.model.location ) - def update_positions(self, data: list[dict]) -> None: + def update_layout(self, data: dict) -> None: ''' Update positions. ''' - lookup = {x['id']: x for x in data} - operations = self.operations() - for item in operations: - if item.pk in lookup: - item.position_x = lookup[item.pk]['position_x'] - item.position_y = lookup[item.pk]['position_y'] - Operation.objects.bulk_update(operations, ['position_x', 'position_y']) + layout = self.layout() + layout.data = data + layout.save() def create_operation(self, **kwargs) -> Operation: ''' Insert new operation. ''' diff --git a/rsconcept/backend/apps/oss/models/__init__.py b/rsconcept/backend/apps/oss/models/__init__.py index e21bc285..143c6a59 100644 --- a/rsconcept/backend/apps/oss/models/__init__.py +++ b/rsconcept/backend/apps/oss/models/__init__.py @@ -1,7 +1,9 @@ ''' Django: Models. ''' from .Argument import Argument +from .Block import Block from .Inheritance import Inheritance +from .Layout import Layout from .Operation import Operation, OperationType from .OperationSchema import OperationSchema from .PropagationFacade import PropagationFacade diff --git a/rsconcept/backend/apps/oss/serializers/__init__.py b/rsconcept/backend/apps/oss/serializers/__init__.py index 874298ce..468c770a 100644 --- a/rsconcept/backend/apps/oss/serializers/__init__.py +++ b/rsconcept/backend/apps/oss/serializers/__init__.py @@ -1,6 +1,6 @@ ''' REST API: Serializers. ''' -from .basics import OperationPositionSerializer, PositionsSerializer, SubstitutionExSerializer +from .basics import LayoutSerializer, SubstitutionExSerializer from .data_access import ( ArgumentSerializer, OperationCreateSerializer, diff --git a/rsconcept/backend/apps/oss/serializers/basics.py b/rsconcept/backend/apps/oss/serializers/basics.py index 9d2328be..11caab87 100644 --- a/rsconcept/backend/apps/oss/serializers/basics.py +++ b/rsconcept/backend/apps/oss/serializers/basics.py @@ -2,17 +2,29 @@ from rest_framework import serializers -class OperationPositionSerializer(serializers.Serializer): +class OperationNodeSerializer(serializers.Serializer): ''' Operation position. ''' id = serializers.IntegerField() - position_x = serializers.FloatField() - position_y = serializers.FloatField() + x = serializers.FloatField() + y = serializers.FloatField() -class PositionsSerializer(serializers.Serializer): - ''' Operations position for OperationSchema. ''' - positions = serializers.ListField( - child=OperationPositionSerializer() +class BlockNodeSerializer(serializers.Serializer): + ''' Block position. ''' + id = serializers.IntegerField() + x = serializers.FloatField() + y = serializers.FloatField() + width = serializers.FloatField() + height = serializers.FloatField() + + +class LayoutSerializer(serializers.Serializer): + ''' Layout for OperationSchema. ''' + blocks = serializers.ListField( + child=BlockNodeSerializer() + ) + operations = serializers.ListField( + child=OperationNodeSerializer() ) diff --git a/rsconcept/backend/apps/oss/serializers/data_access.py b/rsconcept/backend/apps/oss/serializers/data_access.py index b56f75ac..2e7d8f72 100644 --- a/rsconcept/backend/apps/oss/serializers/data_access.py +++ b/rsconcept/backend/apps/oss/serializers/data_access.py @@ -12,7 +12,7 @@ from apps.rsform.serializers import SubstitutionSerializerBase from shared import messages as msg from ..models import Argument, Inheritance, Operation, OperationSchema, OperationType -from .basics import OperationPositionSerializer, SubstitutionExSerializer +from .basics import LayoutSerializer, SubstitutionExSerializer class OperationSerializer(serializers.ModelSerializer): @@ -44,17 +44,16 @@ class OperationCreateSerializer(serializers.Serializer): model = Operation fields = \ 'alias', 'operation_type', 'title', \ - 'description', 'result', 'position_x', 'position_y' + 'description', 'result', 'parent' + + layout = LayoutSerializer() + position_x = serializers.FloatField() + position_y = serializers.FloatField() - create_schema = serializers.BooleanField(default=False, required=False) item_data = OperationCreateData() + create_schema = serializers.BooleanField(default=False, required=False) arguments = PKField(many=True, queryset=Operation.objects.all().only('pk'), required=False) - positions = serializers.ListField( - child=OperationPositionSerializer(), - default=[] - ) - class OperationUpdateSerializer(serializers.Serializer): ''' Serializer: Operation update. ''' @@ -65,6 +64,7 @@ class OperationUpdateSerializer(serializers.Serializer): model = Operation fields = 'alias', 'title', 'description' + layout = LayoutSerializer() target = PKField(many=False, queryset=Operation.objects.all()) item_data = OperationUpdateData() arguments = PKField(many=True, queryset=Operation.objects.all().only('oss_id', 'result_id'), required=False) @@ -73,11 +73,6 @@ class OperationUpdateSerializer(serializers.Serializer): required=False ) - positions = serializers.ListField( - child=OperationPositionSerializer(), - default=[] - ) - def validate(self, attrs): if 'arguments' not in attrs: return attrs @@ -120,11 +115,8 @@ class OperationUpdateSerializer(serializers.Serializer): class OperationTargetSerializer(serializers.Serializer): ''' Serializer: Target single operation. ''' + layout = LayoutSerializer() target = PKField(many=False, queryset=Operation.objects.all().only('oss_id', 'result_id')) - positions = serializers.ListField( - child=OperationPositionSerializer(), - default=[] - ) def validate(self, attrs): oss = cast(LibraryItem, self.context['oss']) @@ -138,11 +130,8 @@ class OperationTargetSerializer(serializers.Serializer): class OperationDeleteSerializer(serializers.Serializer): ''' Serializer: Delete operation. ''' + layout = LayoutSerializer() target = PKField(many=False, queryset=Operation.objects.all().only('oss_id', 'result')) - positions = serializers.ListField( - child=OperationPositionSerializer(), - default=[] - ) keep_constituents = serializers.BooleanField(default=False, required=False) delete_schema = serializers.BooleanField(default=False, required=False) @@ -158,6 +147,7 @@ class OperationDeleteSerializer(serializers.Serializer): class SetOperationInputSerializer(serializers.Serializer): ''' Serializer: Set input schema for operation. ''' + layout = LayoutSerializer() target = PKField(many=False, queryset=Operation.objects.all()) input = PKField( many=False, @@ -165,10 +155,6 @@ class SetOperationInputSerializer(serializers.Serializer): allow_null=True, default=None ) - positions = serializers.ListField( - child=OperationPositionSerializer(), - default=[] - ) def validate(self, attrs): oss = cast(LibraryItem, self.context['oss']) @@ -195,6 +181,7 @@ class OperationSchemaSerializer(serializers.ModelSerializer): substitutions = serializers.ListField( child=SubstitutionExSerializer() ) + layout = LayoutSerializer() class Meta: ''' serializer metadata. ''' @@ -205,6 +192,7 @@ class OperationSchemaSerializer(serializers.ModelSerializer): result = LibraryItemDetailsSerializer(instance).data del result['versions'] oss = OperationSchema(instance) + result['layout'] = oss.layout().data result['items'] = [] for operation in oss.operations().order_by('pk'): result['items'].append(OperationSerializer(operation).data) diff --git a/rsconcept/backend/apps/oss/tests/s_models/t_Operation.py b/rsconcept/backend/apps/oss/tests/s_models/t_Operation.py index 02a6bbe5..efcd9d50 100644 --- a/rsconcept/backend/apps/oss/tests/s_models/t_Operation.py +++ b/rsconcept/backend/apps/oss/tests/s_models/t_Operation.py @@ -25,9 +25,8 @@ class TestOperation(TestCase): def test_create_default(self): self.assertEqual(self.operation.oss, self.oss.model) self.assertEqual(self.operation.operation_type, OperationType.INPUT) + self.assertEqual(self.operation.parent, None) self.assertEqual(self.operation.result, None) self.assertEqual(self.operation.alias, 'KS1') self.assertEqual(self.operation.title, '') self.assertEqual(self.operation.description, '') - self.assertEqual(self.operation.position_x, 0) - self.assertEqual(self.operation.position_y, 0) 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 96dc309d..6a692417 100644 --- a/rsconcept/backend/apps/oss/tests/s_propagation/t_attributes.py +++ b/rsconcept/backend/apps/oss/tests/s_propagation/t_attributes.py @@ -58,6 +58,18 @@ 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': [] + } + layout = self.owned.layout() + layout.data = self.layout_data + layout.save() + @decl_endpoint('/api/library/{item}/set-owner', method='patch') def test_set_owner(self): data = {'user': self.user3.pk} @@ -142,7 +154,7 @@ class TestChangeAttributes(EndpointTester): 'title': 'Test title mod', 'description': 'Comment mod' }, - 'positions': [], + 'layout': self.layout_data } response = self.executeOK(data=data, item=self.owned_id) 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 ae160773..4afd5a38 100644 --- a/rsconcept/backend/apps/oss/tests/s_propagation/t_constituents.py +++ b/rsconcept/backend/apps/oss/tests/s_propagation/t_constituents.py @@ -57,6 +57,18 @@ 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': [] + } + layout = self.owned.layout() + layout.data = self.layout_data + layout.save() + @decl_endpoint('/api/rsforms/{item}/details', method='get') def test_retrieve_inheritance(self): response = self.executeOK(item=self.ks3.model.pk) 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 6db78d94..4ee3e715 100644 --- a/rsconcept/backend/apps/oss/tests/s_propagation/t_operations.py +++ b/rsconcept/backend/apps/oss/tests/s_propagation/t_operations.py @@ -106,6 +106,20 @@ 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': [] + } + layout = self.owned.layout() + layout.data = self.layout_data + layout.save() + def test_oss_setup(self): self.assertEqual(self.ks1.constituents().count(), 3) self.assertEqual(self.ks2.constituents().count(), 3) @@ -117,7 +131,7 @@ class TestChangeOperations(EndpointTester): @decl_endpoint('/api/oss/{item}/delete-operation', method='patch') def test_delete_input_operation(self): data = { - 'positions': [], + 'layout': self.layout_data, 'target': self.operation2.pk } self.executeOK(data=data, item=self.owned_id) @@ -137,7 +151,7 @@ class TestChangeOperations(EndpointTester): @decl_endpoint('/api/oss/{item}/set-input', method='patch') def test_set_input_null(self): data = { - 'positions': [], + 'layout': self.layout_data, 'target': self.operation2.pk, 'input': None } @@ -169,7 +183,7 @@ class TestChangeOperations(EndpointTester): ks6D1 = ks6.insert_new('D1', definition_formal='X1 X2', convention='KS6D1') data = { - 'positions': [], + 'layout': self.layout_data, 'target': self.operation2.pk, 'input': ks6.model.pk } @@ -211,7 +225,7 @@ class TestChangeOperations(EndpointTester): @decl_endpoint('/api/oss/{item}/delete-operation', method='patch') def test_delete_operation_and_constituents(self): data = { - 'positions': [], + 'layout': self.layout_data, 'target': self.operation1.pk, 'keep_constituents': False, 'delete_schema': True @@ -232,7 +246,7 @@ class TestChangeOperations(EndpointTester): @decl_endpoint('/api/oss/{item}/delete-operation', method='patch') def test_delete_operation_keep_constituents(self): data = { - 'positions': [], + 'layout': self.layout_data, 'target': self.operation1.pk, 'keep_constituents': True, 'delete_schema': True @@ -253,7 +267,7 @@ class TestChangeOperations(EndpointTester): @decl_endpoint('/api/oss/{item}/delete-operation', method='patch') def test_delete_operation_keep_schema(self): data = { - 'positions': [], + 'layout': self.layout_data, 'target': self.operation1.pk, 'keep_constituents': True, 'delete_schema': False @@ -283,7 +297,7 @@ class TestChangeOperations(EndpointTester): 'title': 'Test title mod', 'description': 'Comment mod' }, - 'positions': [], + 'layout': self.layout_data, 'substitutions': [ { 'original': self.ks1X1.pk, @@ -317,7 +331,7 @@ class TestChangeOperations(EndpointTester): 'title': 'Test title mod', 'description': 'Comment mod' }, - 'positions': [], + 'layout': self.layout_data, 'arguments': [self.operation1.pk], } @@ -356,7 +370,7 @@ class TestChangeOperations(EndpointTester): data = { 'target': self.operation4.pk, - 'positions': [] + 'layout': self.layout_data } self.executeOK(data=data, item=self.owned_id) self.operation4.refresh_from_db() 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 943e56a6..6d85bc73 100644 --- a/rsconcept/backend/apps/oss/tests/s_propagation/t_substitutions.py +++ b/rsconcept/backend/apps/oss/tests/s_propagation/t_substitutions.py @@ -106,6 +106,20 @@ 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': [] + } + layout = self.owned.layout() + layout.data = self.layout_data + layout.save() + def test_oss_setup(self): self.assertEqual(self.ks1.constituents().count(), 3) @@ -139,10 +153,12 @@ class TestChangeSubstitutions(EndpointTester): @decl_endpoint('/api/rsforms/{schema}/substitute', method='patch') def test_substitute_substitution(self): - data = {'substitutions': [{ - 'original': self.ks2S1.pk, - 'substitution': self.ks2X1.pk - }]} + data = { + 'substitutions': [{ + 'original': self.ks2S1.pk, + 'substitution': self.ks2X1.pk + }] + } self.executeOK(data=data, schema=self.ks2.model.pk) self.ks4D1.refresh_from_db() self.ks4D2.refresh_from_db() diff --git a/rsconcept/backend/apps/oss/tests/s_views/__init__.py b/rsconcept/backend/apps/oss/tests/s_views/__init__.py index 10c776a8..c6532902 100644 --- a/rsconcept/backend/apps/oss/tests/s_views/__init__.py +++ b/rsconcept/backend/apps/oss/tests/s_views/__init__.py @@ -1,2 +1,3 @@ ''' Tests for REST API. ''' +from .t_operations import * from .t_oss import * diff --git a/rsconcept/backend/apps/oss/tests/s_views/t_operations.py b/rsconcept/backend/apps/oss/tests/s_views/t_operations.py new file mode 100644 index 00000000..0840d4d5 --- /dev/null +++ b/rsconcept/backend/apps/oss/tests/s_views/t_operations.py @@ -0,0 +1,447 @@ +''' 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 Constituenta, RSForm +from shared.EndpointTester import EndpointTester, decl_endpoint + + +class TestOssOperations(EndpointTester): + ''' Testing OSS view - operations. ''' + + def setUp(self): + super().setUp() + self.owned = OperationSchema.create(title='Test', alias='T1', owner=self.user) + self.owned_id = self.owned.model.pk + self.unowned = OperationSchema.create(title='Test2', alias='T2') + self.unowned_id = self.unowned.model.pk + self.private = OperationSchema.create(title='Test2', alias='T2', access_policy=AccessPolicy.PRIVATE) + self.private_id = self.private.model.pk + self.invalid_id = self.private.model.pk + 1337 + + def populateData(self): + self.ks1 = RSForm.create( + alias='KS1', + title='Test1', + owner=self.user + ) + self.ks1X1 = self.ks1.insert_new( + 'X1', + term_raw='X1_1', + term_resolved='X1_1' + ) + self.ks2 = RSForm.create( + alias='KS2', + title='Test2', + owner=self.user + ) + self.ks2X1 = self.ks2.insert_new( + 'X2', + term_raw='X1_2', + term_resolved='X1_2' + ) + + self.operation1 = self.owned.create_operation( + alias='1', + operation_type=OperationType.INPUT, + result=self.ks1.model + ) + self.operation2 = self.owned.create_operation( + alias='2', + operation_type=OperationType.INPUT, + result=self.ks2.model + ) + self.operation3 = self.owned.create_operation( + 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': [] + } + layout = self.owned.layout() + layout.data = self.layout_data + layout.save() + + self.owned.set_arguments(self.operation3.pk, [self.operation1, self.operation2]) + self.owned.set_substitutions(self.operation3.pk, [{ + 'original': self.ks1X1, + 'substitution': self.ks2X1 + }]) + + @decl_endpoint('/api/oss/{item}/create-operation', method='post') + def test_create_operation(self): + self.populateData() + self.executeBadData(item=self.owned_id) + + data = { + 'item_data': { + 'alias': 'Test3', + 'title': 'Test title', + 'description': 'Тест кириллицы', + + }, + 'layout': self.layout_data, + 'position_x': 1, + 'position_y': 1 + + } + self.executeBadData(data=data) + + data['item_data']['operation_type'] = 'invalid' + self.executeBadData(data=data) + + data['item_data']['operation_type'] = OperationType.INPUT + self.executeNotFound(data=data, item=self.invalid_id) + + response = self.executeCreated(data=data, item=self.owned_id) + self.assertEqual(len(response.data['oss']['items']), 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] + 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']) + self.assertEqual(new_operation['description'], data['item_data']['description']) + self.assertEqual(new_operation['result'], None) + self.assertEqual(new_operation['parent'], None) + self.assertEqual(item['x'], data['position_x']) + self.assertEqual(item['y'], data['position_y']) + self.operation1.refresh_from_db() + + self.executeForbidden(data=data, item=self.unowned_id) + self.toggle_admin(True) + self.executeCreated(data=data, item=self.unowned_id) + + @decl_endpoint('/api/oss/{item}/create-operation', method='post') + def test_create_operation_arguments(self): + self.populateData() + data = { + 'item_data': { + 'alias': 'Test4', + 'operation_type': OperationType.SYNTHESIS + }, + 'layout': self.layout_data, + 'position_x': 1, + 'position_y': 1, + 'arguments': [self.operation1.pk, self.operation3.pk] + } + response = self.executeCreated(data=data, item=self.owned_id) + self.owned.refresh_from_db() + new_operation = response.data['new_operation'] + arguments = self.owned.arguments() + self.assertTrue(arguments.filter(operation__id=new_operation['id'], argument=self.operation1)) + self.assertTrue(arguments.filter(operation__id=new_operation['id'], argument=self.operation3)) + + @decl_endpoint('/api/oss/{item}/create-operation', method='post') + def test_create_operation_result(self): + self.populateData() + + self.operation1.result = None + self.operation1.save() + + data = { + 'item_data': { + 'alias': 'Test4', + 'operation_type': OperationType.INPUT, + 'result': self.ks1.model.pk + }, + 'layout': self.layout_data, + 'position_x': 1, + 'position_y': 1 + } + response = self.executeCreated(data=data, item=self.owned_id) + self.owned.refresh_from_db() + new_operation = response.data['new_operation'] + self.assertEqual(new_operation['result'], self.ks1.model.pk) + + @decl_endpoint('/api/oss/{item}/create-operation', method='post') + def test_create_operation_schema(self): + self.populateData() + Editor.add(self.owned.model.pk, self.user2.pk) + data = { + 'item_data': { + 'alias': 'Test4', + 'title': 'Test title', + 'description': 'Comment', + 'operation_type': OperationType.INPUT, + 'result': self.ks1.model.pk + }, + 'create_schema': True, + 'layout': self.layout_data, + 'position_x': 1, + 'position_y': 1 + } + self.executeBadData(data=data, item=self.owned_id) + data['item_data']['result'] = None + response = self.executeCreated(data=data, item=self.owned_id) + self.owned.refresh_from_db() + new_operation = response.data['new_operation'] + schema = LibraryItem.objects.get(pk=new_operation['result']) + self.assertEqual(schema.alias, data['item_data']['alias']) + self.assertEqual(schema.title, data['item_data']['title']) + self.assertEqual(schema.description, data['item_data']['description']) + self.assertEqual(schema.visible, False) + self.assertEqual(schema.access_policy, self.owned.model.access_policy) + self.assertEqual(schema.location, self.owned.model.location) + self.assertIn(self.user2, schema.getQ_editors()) + + @decl_endpoint('/api/oss/{item}/delete-operation', method='patch') + def test_delete_operation(self): + self.executeNotFound(item=self.invalid_id) + + self.populateData() + self.executeBadData(item=self.owned_id) + + data = { + 'layout': self.layout_data + } + self.executeBadData(data=data) + + data['target'] = self.operation1.pk + self.toggle_admin(True) + self.executeBadData(data=data, item=self.unowned_id) + self.logout() + self.executeForbidden(data=data, item=self.owned_id) + + self.login() + response = self.executeOK(data=data) + layout = response.data['layout'] + items = [item for item in layout['operations'] if item['id'] == data['target']] + self.assertEqual(len(response.data['items']), 2) + self.assertEqual(len(items), 0) + + @decl_endpoint('/api/oss/{item}/create-input', method='patch') + def test_create_input(self): + self.populateData() + self.executeBadData(item=self.owned_id) + + data = { + 'layout': self.layout_data + } + self.executeBadData(data=data) + + data['target'] = self.operation1.pk + self.toggle_admin(True) + self.executeBadData(data=data, item=self.unowned_id) + self.logout() + self.executeForbidden(data=data, item=self.owned_id) + + self.login() + self.executeBadData(data=data, item=self.owned_id) + + self.operation1.result = None + self.operation1.description = 'TestComment' + self.operation1.title = 'TestTitle' + self.operation1.save() + response = self.executeOK(data=data) + self.operation1.refresh_from_db() + + new_schema = response.data['new_schema'] + self.assertEqual(new_schema['id'], self.operation1.result.pk) + self.assertEqual(new_schema['alias'], self.operation1.alias) + self.assertEqual(new_schema['title'], self.operation1.title) + self.assertEqual(new_schema['description'], self.operation1.description) + + data['target'] = self.operation3.pk + self.executeBadData(data=data) + + @decl_endpoint('/api/oss/{item}/set-input', method='patch') + def test_set_input_null(self): + self.populateData() + self.executeBadData(item=self.owned_id) + + data = { + 'layout': self.layout_data + } + self.executeBadData(data=data) + + data['target'] = self.operation1.pk + data['input'] = None + self.toggle_admin(True) + self.executeBadData(data=data, item=self.unowned_id) + self.logout() + self.executeForbidden(data=data, item=self.owned_id) + + self.login() + response = self.executeOK(data=data) + self.operation1.refresh_from_db() + self.assertEqual(self.operation1.result, None) + + data['input'] = self.ks1.model.pk + self.ks1.model.alias = 'Test42' + self.ks1.model.title = 'Test421' + self.ks1.model.description = 'TestComment42' + self.ks1.save() + response = self.executeOK(data=data) + self.operation1.refresh_from_db() + self.assertEqual(self.operation1.result, self.ks1.model) + self.assertEqual(self.operation1.alias, self.ks1.model.alias) + self.assertEqual(self.operation1.title, self.ks1.model.title) + self.assertEqual(self.operation1.description, self.ks1.model.description) + + @decl_endpoint('/api/oss/{item}/set-input', method='patch') + def test_set_input_change_schema(self): + self.populateData() + self.operation2.result = None + + data = { + 'layout': self.layout_data, + 'target': self.operation1.pk, + 'input': self.ks2.model.pk + } + self.executeBadData(data=data, item=self.owned_id) + + self.ks2.model.visible = False + self.ks2.model.save(update_fields=['visible']) + data = { + 'layout': self.layout_data, + 'target': self.operation2.pk, + 'input': None + } + self.executeOK(data=data, item=self.owned_id) + self.operation2.refresh_from_db() + self.ks2.model.refresh_from_db() + self.assertEqual(self.operation2.result, None) + self.assertEqual(self.ks2.model.visible, True) + + data = { + 'layout': self.layout_data, + 'target': self.operation1.pk, + 'input': self.ks2.model.pk + } + self.executeOK(data=data, item=self.owned_id) + self.operation1.refresh_from_db() + self.assertEqual(self.operation1.result, self.ks2.model) + + @decl_endpoint('/api/oss/{item}/update-operation', method='patch') + def test_update_operation(self): + self.populateData() + self.executeBadData(item=self.owned_id) + + ks3 = RSForm.create(alias='KS3', title='Test3', owner=self.user) + ks3x1 = ks3.insert_new('X1', term_resolved='X1_1') + + data = { + 'target': self.operation3.pk, + 'item_data': { + 'alias': 'Test3 mod', + 'title': 'Test title mod', + 'description': 'Comment mod' + }, + 'layout': self.layout_data, + 'arguments': [self.operation2.pk, self.operation1.pk], + 'substitutions': [ + { + 'original': self.ks1X1.pk, + 'substitution': ks3x1.pk + } + ] + } + self.executeBadData(data=data) + + data['substitutions'][0]['substitution'] = self.ks2X1.pk + self.toggle_admin(True) + self.executeBadData(data=data, item=self.unowned_id) + self.logout() + self.executeForbidden(data=data, item=self.owned_id) + + self.login() + response = self.executeOK(data=data) + self.operation3.refresh_from_db() + self.assertEqual(self.operation3.alias, data['item_data']['alias']) + self.assertEqual(self.operation3.title, data['item_data']['title']) + self.assertEqual(self.operation3.description, data['item_data']['description']) + args = self.operation3.getQ_arguments().order_by('order') + self.assertEqual(args[0].argument.pk, data['arguments'][0]) + self.assertEqual(args[0].order, 0) + self.assertEqual(args[1].argument.pk, data['arguments'][1]) + self.assertEqual(args[1].order, 1) + sub = self.operation3.getQ_substitutions()[0] + self.assertEqual(sub.original.pk, data['substitutions'][0]['original']) + self.assertEqual(sub.substitution.pk, data['substitutions'][0]['substitution']) + + @decl_endpoint('/api/oss/{item}/update-operation', method='patch') + def test_update_operation_sync(self): + self.populateData() + self.executeBadData(item=self.owned_id) + + data = { + 'target': self.operation1.pk, + 'item_data': { + 'alias': 'Test3 mod', + 'title': 'Test title mod', + 'description': 'Comment mod' + }, + 'layout': self.layout_data + } + + response = self.executeOK(data=data) + self.operation1.refresh_from_db() + self.assertEqual(self.operation1.alias, data['item_data']['alias']) + self.assertEqual(self.operation1.title, data['item_data']['title']) + self.assertEqual(self.operation1.description, data['item_data']['description']) + self.assertEqual(self.operation1.result.alias, data['item_data']['alias']) + self.assertEqual(self.operation1.result.title, data['item_data']['title']) + self.assertEqual(self.operation1.result.description, data['item_data']['description']) + + @decl_endpoint('/api/oss/{item}/update-operation', method='patch') + def test_update_operation_invalid_substitution(self): + self.populateData() + + self.ks1X2 = self.ks1.insert_new('X2') + + data = { + 'target': self.operation3.pk, + 'item_data': { + 'alias': 'Test3 mod', + 'title': 'Test title mod', + 'description': 'Comment mod' + }, + 'layout': self.layout_data, + 'arguments': [self.operation1.pk, self.operation2.pk], + 'substitutions': [ + { + 'original': self.ks1X1.pk, + 'substitution': self.ks2X1.pk + }, + { + 'original': self.ks2X1.pk, + 'substitution': self.ks1X2.pk + } + ] + } + self.executeBadData(data=data, item=self.owned_id) + + @decl_endpoint('/api/oss/{item}/execute-operation', method='post') + def test_execute_operation(self): + self.populateData() + self.executeBadData(item=self.owned_id) + + data = { + 'layout': self.layout_data, + 'target': self.operation1.pk + } + self.executeBadData(data=data) + + data['target'] = self.operation3.pk + self.toggle_admin(True) + self.executeBadData(data=data, item=self.unowned_id) + self.logout() + self.executeForbidden(data=data, item=self.owned_id) + + self.login() + self.executeOK(data=data) + self.operation3.refresh_from_db() + schema = self.operation3.result + self.assertEqual(schema.alias, self.operation3.alias) + self.assertEqual(schema.description, self.operation3.description) + self.assertEqual(schema.title, self.operation3.title) + self.assertEqual(schema.visible, False) + items = list(RSForm(schema).constituents()) + self.assertEqual(len(items), 1) + self.assertEqual(items[0].alias, 'X1') + self.assertEqual(items[0].term_resolved, self.ks2X1.term_resolved) diff --git a/rsconcept/backend/apps/oss/tests/s_views/t_oss.py b/rsconcept/backend/apps/oss/tests/s_views/t_oss.py index a6d1fb18..868bdd18 100644 --- a/rsconcept/backend/apps/oss/tests/s_views/t_oss.py +++ b/rsconcept/backend/apps/oss/tests/s_views/t_oss.py @@ -1,6 +1,6 @@ ''' Testing API: Operation Schema. ''' -from apps.library.models import AccessPolicy, Editor, LibraryItem, LibraryItemType -from apps.oss.models import Operation, OperationSchema, OperationType +from apps.library.models import AccessPolicy, LibraryItemType +from apps.oss.models import OperationSchema, OperationType from apps.rsform.models import Constituenta, RSForm from shared.EndpointTester import EndpointTester, decl_endpoint @@ -54,6 +54,14 @@ class TestOssViewset(EndpointTester): alias='3', operation_type=OperationType.SYNTHESIS ) + layout = self.owned.layout() + 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': []} + layout.save() + self.owned.set_arguments(self.operation3.pk, [self.operation1, self.operation2]) self.owned.set_substitutions(self.operation3.pk, [{ 'original': self.ks1X1, @@ -95,6 +103,12 @@ class TestOssViewset(EndpointTester): self.assertEqual(arguments[1]['operation'], self.operation3.pk) 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.executeOK(item=self.unowned_id) self.executeForbidden(item=self.private_id) @@ -103,401 +117,30 @@ class TestOssViewset(EndpointTester): self.executeOK(item=self.unowned_id) self.executeForbidden(item=self.private_id) - @decl_endpoint('/api/oss/{item}/update-positions', method='patch') - def test_update_positions(self): + @decl_endpoint('/api/oss/{item}/update-layout', method='patch') + def test_update_layout(self): self.populateData() self.executeBadData(item=self.owned_id) - data = {'positions': []} + data = {'operations': [], 'blocks': []} self.executeOK(data=data) - data = {'positions': [ - {'id': self.operation1.pk, 'position_x': 42.1, 'position_y': 1337}, - {'id': self.operation2.pk, 'position_x': 36.1, 'position_y': 1437}, - {'id': self.invalid_id, 'position_x': 31, 'position_y': 12}, - ]} + 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': []} self.toggle_admin(True) self.executeOK(data=data, item=self.unowned_id) - self.operation1.refresh_from_db() - self.assertNotEqual(self.operation1.position_x, data['positions'][0]['position_x']) - self.assertNotEqual(self.operation1.position_y, data['positions'][0]['position_y']) self.toggle_admin(False) self.executeOK(data=data, item=self.owned_id) - self.operation1.refresh_from_db() - self.operation2.refresh_from_db() - self.assertEqual(self.operation1.position_x, data['positions'][0]['position_x']) - self.assertEqual(self.operation1.position_y, data['positions'][0]['position_y']) - self.assertEqual(self.operation2.position_x, data['positions'][1]['position_x']) - self.assertEqual(self.operation2.position_y, data['positions'][1]['position_y']) + self.owned.refresh_from_db() + self.assertEqual(self.owned.layout().data, data) 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() - self.executeBadData(item=self.owned_id) - - data = { - 'item_data': { - 'alias': 'Test3', - 'title': 'Test title', - 'description': 'Тест кириллицы', - 'position_x': 1, - 'position_y': 1, - }, - 'positions': [ - {'id': self.operation1.pk, 'position_x': 42.1, 'position_y': 1337} - ] - } - self.executeBadData(data=data) - - data['item_data']['operation_type'] = 'invalid' - self.executeBadData(data=data) - - data['item_data']['operation_type'] = OperationType.INPUT - self.executeNotFound(data=data, item=self.invalid_id) - - response = self.executeCreated(data=data, item=self.owned_id) - self.assertEqual(len(response.data['oss']['items']), 4) - new_operation = response.data['new_operation'] - 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']) - self.assertEqual(new_operation['description'], data['item_data']['description']) - self.assertEqual(new_operation['position_x'], data['item_data']['position_x']) - self.assertEqual(new_operation['position_y'], data['item_data']['position_y']) - self.assertEqual(new_operation['result'], None) - self.operation1.refresh_from_db() - self.assertEqual(self.operation1.position_x, data['positions'][0]['position_x']) - self.assertEqual(self.operation1.position_y, data['positions'][0]['position_y']) - - self.executeForbidden(data=data, item=self.unowned_id) - self.toggle_admin(True) - self.executeCreated(data=data, item=self.unowned_id) - - @decl_endpoint('/api/oss/{item}/create-operation', method='post') - def test_create_operation_arguments(self): - self.populateData() - data = { - 'item_data': { - 'alias': 'Test4', - 'operation_type': OperationType.SYNTHESIS - }, - 'positions': [], - 'arguments': [self.operation1.pk, self.operation3.pk] - } - response = self.executeCreated(data=data, item=self.owned_id) - self.owned.refresh_from_db() - new_operation = response.data['new_operation'] - arguments = self.owned.arguments() - self.assertTrue(arguments.filter(operation__id=new_operation['id'], argument=self.operation1)) - self.assertTrue(arguments.filter(operation__id=new_operation['id'], argument=self.operation3)) - - @decl_endpoint('/api/oss/{item}/create-operation', method='post') - def test_create_operation_result(self): - self.populateData() - - self.operation1.result = None - self.operation1.save() - - data = { - 'item_data': { - 'alias': 'Test4', - 'operation_type': OperationType.INPUT, - 'result': self.ks1.model.pk - }, - 'positions': [], - } - response = self.executeCreated(data=data, item=self.owned_id) - self.owned.refresh_from_db() - new_operation = response.data['new_operation'] - self.assertEqual(new_operation['result'], self.ks1.model.pk) - - @decl_endpoint('/api/oss/{item}/create-operation', method='post') - def test_create_operation_schema(self): - self.populateData() - Editor.add(self.owned.model.pk, self.user2.pk) - data = { - 'item_data': { - 'alias': 'Test4', - 'title': 'Test title', - 'description': 'Comment', - 'operation_type': OperationType.INPUT, - 'result': self.ks1.model.pk - }, - 'create_schema': True, - 'positions': [], - } - self.executeBadData(data=data, item=self.owned_id) - data['item_data']['result'] = None - response = self.executeCreated(data=data, item=self.owned_id) - self.owned.refresh_from_db() - new_operation = response.data['new_operation'] - schema = LibraryItem.objects.get(pk=new_operation['result']) - self.assertEqual(schema.alias, data['item_data']['alias']) - self.assertEqual(schema.title, data['item_data']['title']) - self.assertEqual(schema.description, data['item_data']['description']) - self.assertEqual(schema.visible, False) - self.assertEqual(schema.access_policy, self.owned.model.access_policy) - self.assertEqual(schema.location, self.owned.model.location) - self.assertIn(self.user2, schema.getQ_editors()) - - @decl_endpoint('/api/oss/{item}/delete-operation', method='patch') - def test_delete_operation(self): - self.executeNotFound(item=self.invalid_id) - - self.populateData() - self.executeBadData(item=self.owned_id) - - data = { - 'positions': [] - } - self.executeBadData(data=data) - - data['target'] = self.operation1.pk - self.toggle_admin(True) - self.executeBadData(data=data, item=self.unowned_id) - self.logout() - self.executeForbidden(data=data, item=self.owned_id) - - self.login() - response = self.executeOK(data=data) - self.assertEqual(len(response.data['items']), 2) - - @decl_endpoint('/api/oss/{item}/create-input', method='patch') - def test_create_input(self): - self.populateData() - self.executeBadData(item=self.owned_id) - - data = { - 'positions': [] - } - self.executeBadData(data=data) - - data['target'] = self.operation1.pk - self.toggle_admin(True) - self.executeBadData(data=data, item=self.unowned_id) - self.logout() - self.executeForbidden(data=data, item=self.owned_id) - - self.login() - self.executeBadData(data=data, item=self.owned_id) - - self.operation1.result = None - self.operation1.description = 'TestComment' - self.operation1.title = 'TestTitle' - self.operation1.save() - response = self.executeOK(data=data) - self.operation1.refresh_from_db() - - new_schema = response.data['new_schema'] - self.assertEqual(new_schema['id'], self.operation1.result.pk) - self.assertEqual(new_schema['alias'], self.operation1.alias) - self.assertEqual(new_schema['title'], self.operation1.title) - self.assertEqual(new_schema['description'], self.operation1.description) - - data['target'] = self.operation3.pk - self.executeBadData(data=data) - - @decl_endpoint('/api/oss/{item}/set-input', method='patch') - def test_set_input_null(self): - self.populateData() - self.executeBadData(item=self.owned_id) - - data = { - 'positions': [] - } - self.executeBadData(data=data) - - data['target'] = self.operation1.pk - data['input'] = None - self.toggle_admin(True) - self.executeBadData(data=data, item=self.unowned_id) - self.logout() - self.executeForbidden(data=data, item=self.owned_id) - - self.login() - response = self.executeOK(data=data) - self.operation1.refresh_from_db() - self.assertEqual(self.operation1.result, None) - - data['input'] = self.ks1.model.pk - self.ks1.model.alias = 'Test42' - self.ks1.model.title = 'Test421' - self.ks1.model.description = 'TestComment42' - self.ks1.save() - response = self.executeOK(data=data) - self.operation1.refresh_from_db() - self.assertEqual(self.operation1.result, self.ks1.model) - self.assertEqual(self.operation1.alias, self.ks1.model.alias) - self.assertEqual(self.operation1.title, self.ks1.model.title) - self.assertEqual(self.operation1.description, self.ks1.model.description) - - @decl_endpoint('/api/oss/{item}/set-input', method='patch') - def test_set_input_change_schema(self): - self.populateData() - self.operation2.result = None - - data = { - 'positions': [], - 'target': self.operation1.pk, - 'input': self.ks2.model.pk - } - self.executeBadData(data=data, item=self.owned_id) - - self.ks2.model.visible = False - self.ks2.model.save(update_fields=['visible']) - data = { - 'positions': [], - 'target': self.operation2.pk, - 'input': None - } - self.executeOK(data=data, item=self.owned_id) - self.operation2.refresh_from_db() - self.ks2.model.refresh_from_db() - self.assertEqual(self.operation2.result, None) - self.assertEqual(self.ks2.model.visible, True) - - data = { - 'positions': [], - 'target': self.operation1.pk, - 'input': self.ks2.model.pk - } - self.executeOK(data=data, item=self.owned_id) - self.operation1.refresh_from_db() - self.assertEqual(self.operation1.result, self.ks2.model) - - @decl_endpoint('/api/oss/{item}/update-operation', method='patch') - def test_update_operation(self): - self.populateData() - self.executeBadData(item=self.owned_id) - - ks3 = RSForm.create(alias='KS3', title='Test3', owner=self.user) - ks3x1 = ks3.insert_new('X1', term_resolved='X1_1') - - data = { - 'target': self.operation3.pk, - 'item_data': { - 'alias': 'Test3 mod', - 'title': 'Test title mod', - 'description': 'Comment mod' - }, - 'positions': [], - 'arguments': [self.operation2.pk, self.operation1.pk], - 'substitutions': [ - { - 'original': self.ks1X1.pk, - 'substitution': ks3x1.pk - } - ] - } - self.executeBadData(data=data) - - data['substitutions'][0]['substitution'] = self.ks2X1.pk - self.toggle_admin(True) - self.executeBadData(data=data, item=self.unowned_id) - self.logout() - self.executeForbidden(data=data, item=self.owned_id) - - self.login() - response = self.executeOK(data=data) - self.operation3.refresh_from_db() - self.assertEqual(self.operation3.alias, data['item_data']['alias']) - self.assertEqual(self.operation3.title, data['item_data']['title']) - self.assertEqual(self.operation3.description, data['item_data']['description']) - args = self.operation3.getQ_arguments().order_by('order') - self.assertEqual(args[0].argument.pk, data['arguments'][0]) - self.assertEqual(args[0].order, 0) - self.assertEqual(args[1].argument.pk, data['arguments'][1]) - self.assertEqual(args[1].order, 1) - sub = self.operation3.getQ_substitutions()[0] - self.assertEqual(sub.original.pk, data['substitutions'][0]['original']) - self.assertEqual(sub.substitution.pk, data['substitutions'][0]['substitution']) - - @decl_endpoint('/api/oss/{item}/update-operation', method='patch') - def test_update_operation_sync(self): - self.populateData() - self.executeBadData(item=self.owned_id) - - data = { - 'target': self.operation1.pk, - 'item_data': { - 'alias': 'Test3 mod', - 'title': 'Test title mod', - 'description': 'Comment mod' - }, - 'positions': [], - } - - response = self.executeOK(data=data) - self.operation1.refresh_from_db() - self.assertEqual(self.operation1.alias, data['item_data']['alias']) - self.assertEqual(self.operation1.title, data['item_data']['title']) - self.assertEqual(self.operation1.description, data['item_data']['description']) - self.assertEqual(self.operation1.result.alias, data['item_data']['alias']) - self.assertEqual(self.operation1.result.title, data['item_data']['title']) - self.assertEqual(self.operation1.result.description, data['item_data']['description']) - - @decl_endpoint('/api/oss/{item}/update-operation', method='patch') - def test_update_operation_invalid_substitution(self): - self.populateData() - - self.ks1X2 = self.ks1.insert_new('X2') - - data = { - 'target': self.operation3.pk, - 'item_data': { - 'alias': 'Test3 mod', - 'title': 'Test title mod', - 'description': 'Comment mod' - }, - 'positions': [], - 'arguments': [self.operation1.pk, self.operation2.pk], - 'substitutions': [ - { - 'original': self.ks1X1.pk, - 'substitution': self.ks2X1.pk - }, - { - 'original': self.ks2X1.pk, - 'substitution': self.ks1X2.pk - } - ] - } - self.executeBadData(data=data, item=self.owned_id) - - @decl_endpoint('/api/oss/{item}/execute-operation', method='post') - def test_execute_operation(self): - self.populateData() - self.executeBadData(item=self.owned_id) - - data = { - 'positions': [], - 'target': self.operation1.pk - } - self.executeBadData(data=data) - - data['target'] = self.operation3.pk - self.toggle_admin(True) - self.executeBadData(data=data, item=self.unowned_id) - self.logout() - self.executeForbidden(data=data, item=self.owned_id) - - self.login() - self.executeOK(data=data) - self.operation3.refresh_from_db() - schema = self.operation3.result - self.assertEqual(schema.alias, self.operation3.alias) - self.assertEqual(schema.description, self.operation3.description) - self.assertEqual(schema.title, self.operation3.title) - self.assertEqual(schema.visible, False) - items = list(RSForm(schema).constituents()) - self.assertEqual(len(items), 1) - self.assertEqual(items[0].alias, 'X1') - self.assertEqual(items[0].term_resolved, self.ks2X1.term_resolved) - @decl_endpoint('/api/oss/get-predecessor', method='post') def test_get_predecessor(self): self.populateData() diff --git a/rsconcept/backend/apps/oss/views/oss.py b/rsconcept/backend/apps/oss/views/oss.py index 967ebf36..948d7c5c 100644 --- a/rsconcept/backend/apps/oss/views/oss.py +++ b/rsconcept/backend/apps/oss/views/oss.py @@ -36,9 +36,9 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev def get_permissions(self): ''' Determine permission class. ''' if self.action in [ + 'update_layout', 'create_operation', 'delete_operation', - 'update_positions', 'create_input', 'set_input', 'update_operation', @@ -73,21 +73,21 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev ) @extend_schema( - summary='update positions', + summary='update layout', tags=['OSS'], - request=s.PositionsSerializer, + request=s.LayoutSerializer, responses={ c.HTTP_200_OK: None, c.HTTP_403_FORBIDDEN: None, c.HTTP_404_NOT_FOUND: None } ) - @action(detail=True, methods=['patch'], url_path='update-positions') - def update_positions(self, request: Request, pk) -> HttpResponse: - ''' Endpoint: Update operations positions. ''' - serializer = s.PositionsSerializer(data=request.data) + @action(detail=True, methods=['patch'], url_path='update-layout') + def update_layout(self, request: Request, pk) -> HttpResponse: + ''' Endpoint: Update schema layout. ''' + serializer = s.LayoutSerializer(data=request.data) serializer.is_valid(raise_exception=True) - m.OperationSchema(self.get_object()).update_positions(serializer.validated_data['positions']) + m.OperationSchema(self.get_object()).update_layout(serializer.validated_data) return Response(status=c.HTTP_200_OK) @extend_schema( @@ -108,9 +108,16 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev serializer.is_valid(raise_exception=True) oss = m.OperationSchema(self.get_object()) + layout = serializer.validated_data['layout'] with transaction.atomic(): - oss.update_positions(serializer.validated_data['positions']) new_operation = oss.create_operation(**serializer.validated_data['item_data']) + layout['operations'].append({ + 'id': new_operation.pk, + 'x': serializer.validated_data['position_x'], + 'y': serializer.validated_data['position_y'] + }) + oss.update_layout(layout) + schema = new_operation.result if schema is not None: connected_operations = \ @@ -164,9 +171,11 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev oss = m.OperationSchema(self.get_object()) 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] with transaction.atomic(): - oss.update_positions(serializer.validated_data['positions']) oss.delete_operation(operation.pk, serializer.validated_data['keep_constituents']) + oss.update_layout(layout) if old_schema is not None: if serializer.validated_data['delete_schema']: m.PropagationFacade.before_delete_schema(old_schema) @@ -211,7 +220,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev oss = m.OperationSchema(self.get_object()) with transaction.atomic(): - oss.update_positions(serializer.validated_data['positions']) + oss.update_layout(serializer.validated_data['layout']) schema = oss.create_input(operation) return Response( @@ -262,7 +271,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev if old_schema.is_synced(oss.model): old_schema.visible = True old_schema.save(update_fields=['visible']) - oss.update_positions(serializer.validated_data['positions']) + oss.update_layout(serializer.validated_data['layout']) oss.set_input(target_operation.pk, schema) return Response( status=c.HTTP_200_OK, @@ -292,7 +301,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev operation: m.Operation = cast(m.Operation, serializer.validated_data['target']) oss = m.OperationSchema(self.get_object()) with transaction.atomic(): - oss.update_positions(serializer.validated_data['positions']) + oss.update_layout(serializer.validated_data['layout']) operation.alias = serializer.validated_data['item_data']['alias'] operation.title = serializer.validated_data['item_data']['title'] operation.description = serializer.validated_data['item_data']['description'] @@ -346,7 +355,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev oss = m.OperationSchema(self.get_object()) with transaction.atomic(): - oss.update_positions(serializer.validated_data['positions']) + oss.update_layout(serializer.validated_data['layout']) oss.execute_operation(operation) return Response( diff --git a/rsconcept/frontend/src/features/oss/backend/api.ts b/rsconcept/frontend/src/features/oss/backend/api.ts index 2b45dfbf..7ed20f3e 100644 --- a/rsconcept/frontend/src/features/oss/backend/api.ts +++ b/rsconcept/frontend/src/features/oss/backend/api.ts @@ -39,7 +39,7 @@ export const ossApi = { }); }, - updatePositions: ({ + updateLayout: ({ itemID, positions, isSilent @@ -49,7 +49,7 @@ export const ossApi = { isSilent?: boolean; }) => axiosPatch({ - endpoint: `/api/oss/${itemID}/update-positions`, + endpoint: `/api/oss/${itemID}/update-layout`, request: { data: { positions: positions }, successMessage: isSilent ? undefined : infoMsg.changesSaved diff --git a/rsconcept/frontend/src/features/oss/backend/use-update-positions.tsx b/rsconcept/frontend/src/features/oss/backend/use-update-layout.tsx similarity index 85% rename from rsconcept/frontend/src/features/oss/backend/use-update-positions.tsx rename to rsconcept/frontend/src/features/oss/backend/use-update-layout.tsx index 269eac33..5f6aade0 100644 --- a/rsconcept/frontend/src/features/oss/backend/use-update-positions.tsx +++ b/rsconcept/frontend/src/features/oss/backend/use-update-layout.tsx @@ -7,17 +7,17 @@ import { KEYS } from '@/backend/configuration'; import { ossApi } from './api'; import { type IOperationPosition } from './types'; -export const useUpdatePositions = () => { +export const useUpdateLayout = () => { const client = useQueryClient(); const { updateTimestamp } = useUpdateTimestamp(); const mutation = useMutation({ - mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'update-positions'], - mutationFn: ossApi.updatePositions, + mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'update-layout'], + mutationFn: ossApi.updateLayout, onSuccess: (_, variables) => updateTimestamp(variables.itemID), onError: () => client.invalidateQueries() }); return { - updatePositions: (data: { + updateLayout: (data: { itemID: number; // positions: IOperationPosition[]; isSilent?: boolean; diff --git a/rsconcept/frontend/src/features/oss/dialogs/dlg-relocate-constituents.tsx b/rsconcept/frontend/src/features/oss/dialogs/dlg-relocate-constituents.tsx index d82117c3..eddd6516 100644 --- a/rsconcept/frontend/src/features/oss/dialogs/dlg-relocate-constituents.tsx +++ b/rsconcept/frontend/src/features/oss/dialogs/dlg-relocate-constituents.tsx @@ -18,7 +18,7 @@ import { useDialogsStore } from '@/stores/dialogs'; import { type ICstRelocateDTO, type IOperationPosition, schemaCstRelocate } from '../backend/types'; import { useRelocateConstituents } from '../backend/use-relocate-constituents'; -import { useUpdatePositions } from '../backend/use-update-positions'; +import { useUpdateLayout } from '../backend/use-update-layout'; import { IconRelocationUp } from '../components/icon-relocation-up'; import { type IOperation, type IOperationSchema } from '../models/oss'; import { getRelocateCandidates } from '../models/oss-api'; @@ -32,7 +32,7 @@ export interface DlgRelocateConstituentsProps { export function DlgRelocateConstituents() { const { oss, initialTarget, positions } = useDialogsStore(state => state.props as DlgRelocateConstituentsProps); const { items: libraryItems } = useLibrary(); - const { updatePositions } = useUpdatePositions(); + const { updateLayout: updatePositions } = useUpdateLayout(); const { relocateConstituents } = useRelocateConstituents(); const { diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow.tsx index 43f85afd..23c6a18e 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow.tsx @@ -16,7 +16,7 @@ import { useDialogsStore } from '@/stores/dialogs'; import { PARAMETER } from '@/utils/constants'; import { useMutatingOss } from '../../../backend/use-mutating-oss'; -import { useUpdatePositions } from '../../../backend/use-update-positions'; +import { useUpdateLayout } from '../../../backend/use-update-layout'; import { GRID_SIZE } from '../../../models/oss-api'; import { type OssNode } from '../../../models/oss-layout'; import { useOperationTooltipStore } from '../../../stores/operation-tooltip'; @@ -53,7 +53,7 @@ export function OssFlow() { const edgeStraight = useOSSGraphStore(state => state.edgeStraight); const getPositions = useGetPositions(); - const { updatePositions } = useUpdatePositions(); + const { updateLayout: updatePositions } = useUpdateLayout(); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/toolbar-oss-graph.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/toolbar-oss-graph.tsx index 6dbee7ba..a2ce1b64 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/toolbar-oss-graph.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/toolbar-oss-graph.tsx @@ -6,7 +6,7 @@ import clsx from 'clsx'; import { HelpTopic } from '@/features/help'; import { BadgeHelp } from '@/features/help/components'; import { useOperationExecute } from '@/features/oss/backend/use-operation-execute'; -import { useUpdatePositions } from '@/features/oss/backend/use-update-positions'; +import { useUpdateLayout } from '@/features/oss/backend/use-update-layout'; import { MiniButton } from '@/components/control'; import { @@ -62,7 +62,7 @@ export function ToolbarOssGraph({ const toggleEdgeAnimate = useOSSGraphStore(state => state.toggleEdgeAnimate); const toggleEdgeStraight = useOSSGraphStore(state => state.toggleEdgeStraight); - const { updatePositions } = useUpdatePositions(); + const { updateLayout: updatePositions } = useUpdateLayout(); const { operationExecute } = useOperationExecute(); const showEditOperation = useDialogsStore(state => state.showEditOperation);