From a7428a4af4efb80aaa90ba46be860a4029b65b1e Mon Sep 17 00:00:00 2001 From: Ivan <8611739+IRBorisov@users.noreply.github.com> Date: Thu, 31 Jul 2025 20:23:35 +0300 Subject: [PATCH] F: Implementing Reference operation pt1 --- rsconcept/backend/apps/library/admin.py | 12 +-- rsconcept/backend/apps/oss/admin.py | 18 ++-- .../0014_alter_operation_operation_type.py | 18 ++++ .../apps/oss/migrations/0015_reference.py | 27 +++++ .../backend/apps/oss/models/Operation.py | 36 ++++++- .../apps/oss/models/OperationSchema.py | 40 +++++++- .../backend/apps/oss/models/Reference.py | 27 +++++ rsconcept/backend/apps/oss/models/__init__.py | 1 + .../backend/apps/oss/serializers/__init__.py | 2 + .../apps/oss/serializers/data_access.py | 56 ++++++++++- .../oss/tests/s_propagation/t_operations.py | 1 - .../apps/oss/tests/s_views/t_operations.py | 99 ++++++++++++++++++- rsconcept/backend/apps/oss/views/oss.py | 80 +++++++++++++++ .../backend/apps/rsform/models/RSForm.py | 2 +- rsconcept/backend/apps/users/admin.py | 6 +- rsconcept/backend/shared/messages.py | 8 ++ 16 files changed, 402 insertions(+), 31 deletions(-) create mode 100644 rsconcept/backend/apps/oss/migrations/0014_alter_operation_operation_type.py create mode 100644 rsconcept/backend/apps/oss/migrations/0015_reference.py create mode 100644 rsconcept/backend/apps/oss/models/Reference.py diff --git a/rsconcept/backend/apps/library/admin.py b/rsconcept/backend/apps/library/admin.py index 35d34ab7..932405b8 100644 --- a/rsconcept/backend/apps/library/admin.py +++ b/rsconcept/backend/apps/library/admin.py @@ -1,10 +1,12 @@ ''' Admin view: Library. ''' from typing import cast + from django.contrib import admin from . import models +@admin.register(models.LibraryItem) class LibraryItemAdmin(admin.ModelAdmin): ''' Admin model: LibraryItem. ''' date_hierarchy = 'time_update' @@ -17,6 +19,7 @@ class LibraryItemAdmin(admin.ModelAdmin): search_fields = ['alias', 'title', 'location'] +@admin.register(models.LibraryTemplate) class LibraryTemplateAdmin(admin.ModelAdmin): ''' Admin model: LibraryTemplate. ''' list_display = ['id', 'alias'] @@ -29,6 +32,7 @@ class LibraryTemplateAdmin(admin.ModelAdmin): return 'N/A' +@admin.register(models.Editor) class EditorAdmin(admin.ModelAdmin): ''' Admin model: Editors. ''' list_display = ['id', 'item', 'editor'] @@ -38,16 +42,10 @@ class EditorAdmin(admin.ModelAdmin): ] +@admin.register(models.Version) class VersionAdmin(admin.ModelAdmin): ''' Admin model: Versions. ''' list_display = ['id', 'item', 'version', 'description', 'time_create'] search_fields = [ 'item__title', 'item__alias' ] - - - -admin.site.register(models.LibraryItem, LibraryItemAdmin) -admin.site.register(models.LibraryTemplate, LibraryTemplateAdmin) -admin.site.register(models.Version, VersionAdmin) -admin.site.register(models.Editor, EditorAdmin) diff --git a/rsconcept/backend/apps/oss/admin.py b/rsconcept/backend/apps/oss/admin.py index c88e118b..3a028708 100644 --- a/rsconcept/backend/apps/oss/admin.py +++ b/rsconcept/backend/apps/oss/admin.py @@ -4,6 +4,7 @@ from django.contrib import admin from . import models +@admin.register(models.Operation) class OperationAdmin(admin.ModelAdmin): ''' Admin model: Operation. ''' ordering = ['oss'] @@ -19,6 +20,7 @@ class OperationAdmin(admin.ModelAdmin): search_fields = ['id', 'operation_type', 'title', 'alias'] +@admin.register(models.Block) class BlockAdmin(admin.ModelAdmin): ''' Admin model: Block. ''' ordering = ['oss'] @@ -26,6 +28,7 @@ class BlockAdmin(admin.ModelAdmin): search_fields = ['oss'] +@admin.register(models.Layout) class LayoutAdmin(admin.ModelAdmin): ''' Admin model: Layout. ''' ordering = ['oss'] @@ -33,6 +36,7 @@ class LayoutAdmin(admin.ModelAdmin): search_fields = ['oss'] +@admin.register(models.Argument) class ArgumentAdmin(admin.ModelAdmin): ''' Admin model: Operation arguments. ''' ordering = ['operation'] @@ -40,6 +44,7 @@ class ArgumentAdmin(admin.ModelAdmin): search_fields = ['id', 'operation', 'argument'] +@admin.register(models.Substitution) class SynthesisSubstitutionAdmin(admin.ModelAdmin): ''' Admin model: Substitutions as part of Synthesis operation. ''' ordering = ['operation'] @@ -47,6 +52,7 @@ class SynthesisSubstitutionAdmin(admin.ModelAdmin): search_fields = ['id', 'operation', 'original', 'substitution'] +@admin.register(models.Inheritance) class InheritanceAdmin(admin.ModelAdmin): ''' Admin model: Inheritance. ''' ordering = ['operation'] @@ -54,9 +60,9 @@ class InheritanceAdmin(admin.ModelAdmin): search_fields = ['id', 'operation', 'parent', 'child'] -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) +@admin.register(models.Reference) +class ReferenceAdmin(admin.ModelAdmin): + ''' Admin model: Reference. ''' + ordering = ['reference', 'target'] + list_display = ['id', 'reference', 'target'] + search_fields = ['id', 'reference', 'target'] diff --git a/rsconcept/backend/apps/oss/migrations/0014_alter_operation_operation_type.py b/rsconcept/backend/apps/oss/migrations/0014_alter_operation_operation_type.py new file mode 100644 index 00000000..9a88cde8 --- /dev/null +++ b/rsconcept/backend/apps/oss/migrations/0014_alter_operation_operation_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-07-31 08:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oss', '0013_alter_layout_data'), + ] + + operations = [ + migrations.AlterField( + model_name='operation', + name='operation_type', + field=models.CharField(choices=[('input', 'Input'), ('synthesis', 'Synthesis'), ('reference', 'Reference')], default='input', max_length=10, verbose_name='Тип'), + ), + ] diff --git a/rsconcept/backend/apps/oss/migrations/0015_reference.py b/rsconcept/backend/apps/oss/migrations/0015_reference.py new file mode 100644 index 00000000..1b800c7a --- /dev/null +++ b/rsconcept/backend/apps/oss/migrations/0015_reference.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.4 on 2025-07-31 08:31 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oss', '0014_alter_operation_operation_type'), + ] + + operations = [ + migrations.CreateModel( + name='Reference', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('reference', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='references', to='oss.operation', verbose_name='Отсылка')), + ('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='targets', to='oss.operation', verbose_name='Целевая Операция')), + ], + options={ + 'verbose_name': 'Отсылка', + 'verbose_name_plural': 'Отсылки', + 'unique_together': {('reference', 'target')}, + }, + ), + ] diff --git a/rsconcept/backend/apps/oss/models/Operation.py b/rsconcept/backend/apps/oss/models/Operation.py index ffc1e4fa..df4b07f2 100644 --- a/rsconcept/backend/apps/oss/models/Operation.py +++ b/rsconcept/backend/apps/oss/models/Operation.py @@ -1,5 +1,7 @@ ''' Models: Operation in OSS. ''' # pylint: disable=duplicate-code +from typing import Optional + from django.db.models import ( CASCADE, SET_NULL, @@ -11,7 +13,10 @@ from django.db.models import ( TextField ) +from apps.library.models import LibraryItem + from .Argument import Argument +from .Reference import Reference from .Substitution import Substitution @@ -19,6 +24,7 @@ class OperationType(TextChoices): ''' Type of operation. ''' INPUT = 'input' SYNTHESIS = 'synthesis' + REFERENCE = 'reference' class Operation(Model): @@ -76,9 +82,37 @@ class Operation(Model): return f'Операция {self.alias}' def getQ_arguments(self) -> QuerySet[Argument]: - ''' Operation arguments. ''' + ''' Operation Arguments for current operation. ''' return Argument.objects.filter(operation=self) + def getQ_as_argument(self) -> QuerySet[Argument]: + ''' Operation Arguments where the operation is used as an argument. ''' + return Argument.objects.filter(argument=self) + def getQ_substitutions(self) -> QuerySet[Substitution]: ''' Operation substitutions. ''' return Substitution.objects.filter(operation=self) + + def getQ_references(self) -> QuerySet[Reference]: + ''' Operation references. ''' + return Reference.objects.filter(target=self) + + def getQ_reference_target(self) -> list['Operation']: + ''' Operation target for current reference. ''' + return [x.target for x in Reference.objects.filter(reference=self)] + + def setQ_result(self, result: Optional[LibraryItem]) -> None: + ''' Set result schema. ''' + if result == self.result: + return + self.result = result + self.save(update_fields=['result']) + for reference in self.getQ_references(): + reference.reference.result = result + reference.reference.save(update_fields=['result']) + + def delete(self, *args, **kwargs): + ''' Delete operation. ''' + for ref in self.getQ_references(): + ref.reference.delete() + super().delete(*args, **kwargs) diff --git a/rsconcept/backend/apps/oss/models/OperationSchema.py b/rsconcept/backend/apps/oss/models/OperationSchema.py index 2f7114bf..13a89793 100644 --- a/rsconcept/backend/apps/oss/models/OperationSchema.py +++ b/rsconcept/backend/apps/oss/models/OperationSchema.py @@ -22,7 +22,8 @@ from .Argument import Argument from .Block import Block from .Inheritance import Inheritance from .Layout import Layout -from .Operation import Operation +from .Operation import Operation, OperationType +from .Reference import Reference from .Substitution import Substitution CstMapping = dict[str, Optional[Constituenta]] @@ -105,12 +106,41 @@ class OperationSchema: self.save(update_fields=['time_update']) return result + def create_reference(self, target: Operation) -> Operation: + ''' Create Reference Operation. ''' + result = Operation.objects.create( + oss=self.model, + operation_type=OperationType.REFERENCE, + result=target.result, + parent=target.parent + ) + Reference.objects.create(reference=result, target=target) + self.save(update_fields=['time_update']) + return result + def create_block(self, **kwargs) -> Block: ''' Create Block. ''' result = Block.objects.create(oss=self.model, **kwargs) self.save(update_fields=['time_update']) return result + def delete_reference(self, target: Operation, keep_connections: bool = False): + ''' Delete Reference Operation. ''' + if keep_connections: + referred_operations = target.getQ_reference_target() + if len(referred_operations) == 1: + referred_operation = referred_operations[0] + for arg in target.getQ_as_argument(): + arg.pk = None + arg.argument = referred_operation + arg.save() + else: + pass + # if target.result_id is not None: + # self.before_delete_cst(schema, schema.cache.constituents) # TODO: use operation instead of schema + target.delete() + self.save(update_fields=['time_update']) + def delete_operation(self, target: int, keep_constituents: bool = False): ''' Delete Operation. ''' self.cache.ensure_loaded() @@ -167,12 +197,12 @@ class OperationSchema: self.before_delete_cst(old_schema, old_schema.cache.constituents) self.cache.remove_schema(old_schema) - operation.result = schema + operation.setQ_result(schema) if schema is not None: operation.alias = schema.alias operation.title = schema.title operation.description = schema.description - operation.save(update_fields=['result', 'alias', 'title', 'description']) + operation.save(update_fields=['alias', 'title', 'description']) if schema is not None and has_children: rsform = RSForm(schema) @@ -263,8 +293,7 @@ class OperationSchema: location=self.model.location ) Editor.set(schema.model.pk, self.model.getQ_editors().values_list('pk', flat=True)) - operation.result = schema.model - operation.save() + operation.setQ_result(schema.model) self.save(update_fields=['time_update']) return schema @@ -926,6 +955,7 @@ class OssCache: self.inheritance[target.operation_id].remove(target) def unfold_sub(self, sub: Substitution) -> tuple[RSForm, RSForm, Constituenta, Constituenta]: + ''' Unfold substitution into original and substitution forms. ''' operation = self.operation_by_id[sub.operation_id] parents = self.graph.inputs[operation.pk] original_cst = None diff --git a/rsconcept/backend/apps/oss/models/Reference.py b/rsconcept/backend/apps/oss/models/Reference.py new file mode 100644 index 00000000..40215002 --- /dev/null +++ b/rsconcept/backend/apps/oss/models/Reference.py @@ -0,0 +1,27 @@ +''' Models: Operation Reference in OSS. ''' +from django.db.models import CASCADE, ForeignKey, Model + + +class Reference(Model): + ''' Operation Reference. ''' + reference = ForeignKey( + verbose_name='Отсылка', + to='oss.Operation', + on_delete=CASCADE, + related_name='references' + ) + target = ForeignKey( + verbose_name='Целевая Операция', + to='oss.Operation', + on_delete=CASCADE, + related_name='targets' + ) + + class Meta: + ''' Model metadata. ''' + verbose_name = 'Отсылка' + verbose_name_plural = 'Отсылки' + unique_together = [['reference', 'target']] + + def __str__(self) -> str: + return f'{self.reference} -> {self.target}' diff --git a/rsconcept/backend/apps/oss/models/__init__.py b/rsconcept/backend/apps/oss/models/__init__.py index 143c6a59..8a9913e6 100644 --- a/rsconcept/backend/apps/oss/models/__init__.py +++ b/rsconcept/backend/apps/oss/models/__init__.py @@ -7,4 +7,5 @@ from .Layout import Layout from .Operation import Operation, OperationType from .OperationSchema import OperationSchema from .PropagationFacade import PropagationFacade +from .Reference import Reference from .Substitution import Substitution diff --git a/rsconcept/backend/apps/oss/serializers/__init__.py b/rsconcept/backend/apps/oss/serializers/__init__.py index f29fd79a..94fc2100 100644 --- a/rsconcept/backend/apps/oss/serializers/__init__.py +++ b/rsconcept/backend/apps/oss/serializers/__init__.py @@ -6,10 +6,12 @@ from .data_access import ( BlockSerializer, CloneSchemaSerializer, CreateBlockSerializer, + CreateReferenceSerializer, CreateSchemaSerializer, CreateSynthesisSerializer, DeleteBlockSerializer, DeleteOperationSerializer, + DeleteReferenceSerializer, ImportSchemaSerializer, MoveItemsSerializer, OperationSchemaSerializer, diff --git a/rsconcept/backend/apps/oss/serializers/data_access.py b/rsconcept/backend/apps/oss/serializers/data_access.py index 204fa1a4..da379666 100644 --- a/rsconcept/backend/apps/oss/serializers/data_access.py +++ b/rsconcept/backend/apps/oss/serializers/data_access.py @@ -234,6 +234,30 @@ class CloneSchemaSerializer(StrictSerializer): raise serializers.ValidationError({ 'source_operation': msg.operationResultEmpty(source_operation.alias) }) + if source_operation.operation_type == OperationType.REFERENCE: + raise serializers.ValidationError({ + 'source_operation': msg.referenceTypeNotAllowed() + }) + return attrs + + +class CreateReferenceSerializer(StrictSerializer): + ''' Serializer: Create reference operation. ''' + layout = serializers.ListField(child=NodeSerializer()) + target = PKField(many=False, queryset=Operation.objects.all()) + position = PositionSerializer() + + def validate(self, attrs): + oss = cast(LibraryItem, self.context['oss']) + target = cast(Operation, attrs['target']) + if target.oss_id != oss.pk: + raise serializers.ValidationError({ + 'target_operation': msg.operationNotInOSS() + }) + if target.operation_type == OperationType.REFERENCE: + raise serializers.ValidationError({ + 'target_operation': msg.referenceTypeNotAllowed() + }) return attrs @@ -267,7 +291,7 @@ class CreateSynthesisSerializer(StrictSerializer): arguments = PKField( many=True, - queryset=Operation.objects.all().only('pk') + queryset=Operation.objects.all().only('pk', 'result_id') ) substitutions = serializers.ListField( child=SubstitutionSerializerBase(), @@ -391,11 +415,11 @@ class UpdateOperationSerializer(StrictSerializer): class DeleteOperationSerializer(StrictSerializer): - ''' Serializer: Delete operation. ''' + ''' Serializer: Delete non-reference operation. ''' layout = serializers.ListField( child=NodeSerializer() ) - target = PKField(many=False, queryset=Operation.objects.all().only('oss_id', 'result')) + target = PKField(many=False, queryset=Operation.objects.all().only('oss_id', 'operation_type', 'result')) keep_constituents = serializers.BooleanField(default=False, required=False) delete_schema = serializers.BooleanField(default=False, required=False) @@ -406,6 +430,32 @@ class DeleteOperationSerializer(StrictSerializer): raise serializers.ValidationError({ 'target': msg.operationNotInOSS() }) + if operation.operation_type == OperationType.REFERENCE: + raise serializers.ValidationError({ + 'target': msg.referenceTypeNotAllowed() + }) + return attrs + + +class DeleteReferenceSerializer(StrictSerializer): + ''' Serializer: Delete reference operation. ''' + layout = serializers.ListField( + child=NodeSerializer() + ) + target = PKField(many=False, queryset=Operation.objects.all().only('oss_id', 'operation_type')) + keep_connections = serializers.BooleanField(default=False, required=False) + + def validate(self, attrs): + oss = cast(LibraryItem, self.context['oss']) + operation = cast(Operation, attrs['target']) + if operation.oss_id != oss.pk: + raise serializers.ValidationError({ + 'target': msg.operationNotInOSS() + }) + if operation.operation_type != OperationType.REFERENCE: + raise serializers.ValidationError({ + 'target': msg.referenceTypeRequired() + }) return attrs 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 863fe423..5a7f6554 100644 --- a/rsconcept/backend/apps/oss/tests/s_propagation/t_operations.py +++ b/rsconcept/backend/apps/oss/tests/s_propagation/t_operations.py @@ -8,7 +8,6 @@ from shared.EndpointTester import EndpointTester, decl_endpoint class TestChangeOperations(EndpointTester): ''' Testing Operations change propagation in OSS. ''' - def setUp(self): super().setUp() self.owned = OperationSchema.create( 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 074ef879..a053c03b 100644 --- a/rsconcept/backend/apps/oss/tests/s_views/t_operations.py +++ b/rsconcept/backend/apps/oss/tests/s_views/t_operations.py @@ -1,6 +1,6 @@ ''' Testing API: Operation Schema - operations manipulation. ''' from apps.library.models import AccessPolicy, Editor, LibraryItem, LibraryItemType -from apps.oss.models import Operation, OperationSchema, OperationType +from apps.oss.models import Operation, OperationSchema, OperationType, Reference from apps.rsform.models import Constituenta, RSForm from shared.EndpointTester import EndpointTester, decl_endpoint @@ -54,6 +54,11 @@ class TestOssOperations(EndpointTester): alias='3', operation_type=OperationType.SYNTHESIS ) + self.unowned_operation = self.unowned.create_operation( + alias='42', + operation_type=OperationType.INPUT, + result=None + ) 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}, @@ -69,7 +74,6 @@ class TestOssOperations(EndpointTester): 'substitution': self.ks2X1 }]) - @decl_endpoint('/api/oss/{item}/create-schema', method='post') def test_create_schema(self): self.populateData() @@ -165,6 +169,10 @@ class TestOssOperations(EndpointTester): self.assertEqual(new_schema.description, new_operation['description']) self.assertEqual(self.ks1.constituents().count(), RSForm(new_schema).constituents().count()) + unrelated_data = dict(data) + unrelated_data['source_operation'] = self.unowned_operation.pk + self.executeBadData(data=unrelated_data, item=self.owned_id) + @decl_endpoint('/api/oss/{item}/create-schema', method='post') def test_create_schema_parent(self): @@ -201,6 +209,37 @@ class TestOssOperations(EndpointTester): self.assertEqual(new_operation['parent'], block_owned.id) + @decl_endpoint('/api/oss/{item}/create-reference', method='post') + def test_create_reference(self): + self.populateData() + data = { + 'target': self.invalid_id, + 'layout': self.layout_data, + 'position': { + 'x': 10, + 'y': 20, + 'width': 100, + 'height': 40 + } + } + self.executeBadData(data=data, item=self.owned_id) + + data['target'] = self.unowned_operation.pk + self.executeBadData(data=data, item=self.owned_id) + + data['target'] = self.operation1.pk + response = self.executeCreated(data=data, item=self.owned_id) + self.owned.refresh_from_db() + new_operation_id = response.data['new_operation'] + new_operation = next(op for op in response.data['oss']['operations'] if op['id'] == new_operation_id) + self.assertEqual(new_operation['operation_type'], OperationType.REFERENCE) + self.assertEqual(new_operation['parent'], self.operation1.parent_id) + self.assertEqual(new_operation['result'], self.operation1.result_id) + ref = Reference.objects.filter(reference_id=new_operation_id, target_id=self.operation1.pk).first() + self.assertIsNotNone(ref) + self.assertTrue(Operation.objects.filter(pk=new_operation_id, oss=self.owned.model).exists()) + + @decl_endpoint('/api/oss/{item}/create-synthesis', method='post') def test_create_synthesis(self): self.populateData() @@ -242,6 +281,9 @@ class TestOssOperations(EndpointTester): } self.executeBadData(data=data) + data['target'] = self.unowned_operation.pk + self.executeBadData(data=data, item=self.owned_id) + data['target'] = self.operation1.pk self.toggle_admin(True) self.executeBadData(data=data, item=self.unowned_id) @@ -256,6 +298,39 @@ class TestOssOperations(EndpointTester): self.assertEqual(len(deleted_items), 0) + @decl_endpoint('/api/oss/{item}/delete-operation', method='patch') + def test_delete_reference_operation_invalid(self): + self.populateData() + reference_operation = self.owned.create_reference(self.operation1) + data = { + 'layout': self.layout_data, + 'target': reference_operation.pk + } + self.executeBadData(data=data, item=self.owned_id) + + + @decl_endpoint('/api/oss/{item}/delete-reference', method='patch') + def test_delete_reference_operation(self): + self.populateData() + data = { + 'layout': self.layout_data, + 'target': self.invalid_id + } + self.executeBadData(data=data, item=self.owned_id) + + reference_operation = self.owned.create_reference(self.operation1) + self.assertEqual(len(self.operation1.getQ_references()), 1) + data['target'] = reference_operation.pk + self.executeForbidden(data=data, item=self.unowned_id) + + data['target'] = self.operation1.pk + self.executeBadData(data=data, item=self.owned_id) + + data['target'] = reference_operation.pk + self.executeOK(data=data, item=self.owned_id) + self.assertEqual(len(self.operation1.getQ_references()), 0) + + @decl_endpoint('/api/oss/{item}/create-input', method='patch') def test_create_input(self): self.populateData() @@ -291,6 +366,9 @@ class TestOssOperations(EndpointTester): data['target'] = self.operation3.pk self.executeBadData(data=data) + data['target'] = self.unowned_operation.pk + self.executeBadData(data=data, item=self.owned_id) + @decl_endpoint('/api/oss/{item}/set-input', method='patch') def test_set_input_null(self): @@ -411,6 +489,10 @@ class TestOssOperations(EndpointTester): data['layout'] = self.layout_data self.executeOK(data=data) + data_bad = dict(data) + data_bad['target'] = self.unowned_operation.pk + self.executeBadData(data=data_bad, item=self.owned_id) + @decl_endpoint('/api/oss/{item}/update-operation', method='patch') def test_update_operation_sync(self): @@ -418,7 +500,7 @@ class TestOssOperations(EndpointTester): self.executeBadData(item=self.owned_id) data = { - 'target': self.operation1.pk, + 'target': self.unowned_operation.pk, 'item_data': { 'alias': 'Test3 mod', 'title': 'Test title mod', @@ -426,7 +508,9 @@ class TestOssOperations(EndpointTester): }, 'layout': self.layout_data } + self.executeBadData(data=data, item=self.owned_id) + data['target'] = self.operation1.pk response = self.executeOK(data=data) self.operation1.refresh_from_db() self.assertEqual(self.operation1.alias, data['item_data']['alias']) @@ -436,6 +520,11 @@ class TestOssOperations(EndpointTester): self.assertEqual(self.operation1.result.title, data['item_data']['title']) self.assertEqual(self.operation1.result.description, data['item_data']['description']) + # Try to update an operation from an unrelated OSS (should fail) + data_bad = dict(data) + data_bad['target'] = self.unowned_operation.pk + self.executeBadData(data=data_bad, item=self.owned_id) + @decl_endpoint('/api/oss/{item}/update-operation', method='patch') def test_update_operation_invalid_substitution(self): @@ -477,6 +566,9 @@ class TestOssOperations(EndpointTester): } self.executeBadData(data=data) + data['target'] = self.unowned_operation.pk + self.executeBadData(data=data, item=self.owned_id) + data['target'] = self.operation3.pk self.toggle_admin(True) self.executeBadData(data=data, item=self.unowned_id) @@ -583,6 +675,7 @@ class TestOssOperations(EndpointTester): self.assertEqual(schema.access_policy, self.owned.model.access_policy) self.assertEqual(schema.location, self.owned.model.location) + @decl_endpoint('/api/oss/{item}/import-schema', method='post') def test_import_schema_bad_data(self): self.populateData() diff --git a/rsconcept/backend/apps/oss/views/oss.py b/rsconcept/backend/apps/oss/views/oss.py index 0911bf90..900f5dce 100644 --- a/rsconcept/backend/apps/oss/views/oss.py +++ b/rsconcept/backend/apps/oss/views/oss.py @@ -66,9 +66,11 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev 'create_schema', 'clone_schema', 'import_schema', + 'create_reference', 'create_synthesis', 'update_operation', 'delete_operation', + 'delete_reference', 'create_input', 'set_input', 'execute_operation', @@ -453,6 +455,51 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev } ) + + @extend_schema( + summary='create reference for operation', + tags=['OSS'], + request=s.CreateReferenceSerializer(), + responses={ + c.HTTP_201_CREATED: s.OperationCreatedResponse, + c.HTTP_400_BAD_REQUEST: None, + c.HTTP_403_FORBIDDEN: None, + c.HTTP_404_NOT_FOUND: None + } + ) + @action(detail=True, methods=['post'], url_path='create-reference') + def create_reference(self, request: Request, pk) -> HttpResponse: + ''' Clone schema. ''' + serializer = s.CreateReferenceSerializer( + data=request.data, + context={'oss': self.get_object()} + ) + serializer.is_valid(raise_exception=True) + + oss = m.OperationSchema(self.get_object()) + layout = serializer.validated_data['layout'] + position = serializer.validated_data['position'] + with transaction.atomic(): + target = cast(m.Operation, serializer.validated_data['target']) + new_operation = oss.create_reference(target) + layout.append({ + 'nodeID': 'o' + str(new_operation.pk), + 'x': position['x'], + 'y': position['y'], + 'width': position['width'], + 'height': position['height'] + }) + oss.update_layout(layout) + oss.save(update_fields=['time_update']) + + return Response( + status=c.HTTP_201_CREATED, + data={ + 'new_operation': new_operation.pk, + 'oss': s.OperationSchemaSerializer(oss.model).data + } + ) + @extend_schema( summary='create synthesis operation', tags=['OSS'], @@ -596,6 +643,39 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev data=s.OperationSchemaSerializer(oss.model).data ) + @extend_schema( + summary='delete reference', + tags=['OSS'], + request=s.DeleteReferenceSerializer(), + responses={ + c.HTTP_200_OK: s.OperationSchemaSerializer, + c.HTTP_400_BAD_REQUEST: None, + c.HTTP_403_FORBIDDEN: None, + c.HTTP_404_NOT_FOUND: None + } + ) + @action(detail=True, methods=['patch'], url_path='delete-reference') + def delete_reference(self, request: Request, pk) -> HttpResponse: + ''' Endpoint: Delete Reference Operation. ''' + serializer = s.DeleteReferenceSerializer( + data=request.data, + context={'oss': self.get_object()} + ) + serializer.is_valid(raise_exception=True) + + oss = m.OperationSchema(self.get_object()) + operation = cast(m.Operation, serializer.validated_data['target']) + layout = serializer.validated_data['layout'] + layout = [x for x in layout if x['nodeID'] != 'o' + str(operation.pk)] + with transaction.atomic(): + oss.update_layout(layout) + oss.delete_reference(operation, serializer.validated_data['keep_connections']) + + return Response( + status=c.HTTP_200_OK, + data=s.OperationSchemaSerializer(oss.model).data + ) + @extend_schema( summary='create input schema for target operation', tags=['OSS'], diff --git a/rsconcept/backend/apps/rsform/models/RSForm.py b/rsconcept/backend/apps/rsform/models/RSForm.py index 39fae1f5..73d13f5e 100644 --- a/rsconcept/backend/apps/rsform/models/RSForm.py +++ b/rsconcept/backend/apps/rsform/models/RSForm.py @@ -64,7 +64,7 @@ class RSForm: def refresh_from_db(self) -> None: ''' Model wrapper. ''' self.model.refresh_from_db() - self.cache = RSFormCache(self) + self.cache.is_loaded = False def constituents(self) -> QuerySet[Constituenta]: ''' Get QuerySet containing all constituents of current RSForm. ''' diff --git a/rsconcept/backend/apps/users/admin.py b/rsconcept/backend/apps/users/admin.py index 317e45f0..2aabac79 100644 --- a/rsconcept/backend/apps/users/admin.py +++ b/rsconcept/backend/apps/users/admin.py @@ -4,8 +4,10 @@ from django.contrib.auth import get_user_model from django.contrib.auth.admin import UserAdmin User = get_user_model() +admin.site.unregister(User) +@admin.register(User) class CustomUserAdmin(UserAdmin): ''' Admin model: User. ''' fieldsets = UserAdmin.fieldsets @@ -21,7 +23,3 @@ class CustomUserAdmin(UserAdmin): ordering = ['date_joined', 'username'] search_fields = ['email', 'first_name', 'last_name', 'username'] list_filter = ['is_staff', 'is_superuser', 'is_active'] - - -admin.site.unregister(User) -admin.site.register(User, CustomUserAdmin) diff --git a/rsconcept/backend/shared/messages.py b/rsconcept/backend/shared/messages.py index 74d6ba74..106c7a6f 100644 --- a/rsconcept/backend/shared/messages.py +++ b/rsconcept/backend/shared/messages.py @@ -86,6 +86,14 @@ def operationInputAlreadyConnected(): return 'Схема уже подключена к другой операции' +def referenceTypeNotAllowed(): + return 'Ссылки не поддерживаются' + + +def referenceTypeRequired(): + return 'Операция должна быть ссылкой' + + def operationNotSynthesis(title: str): return f'Операция не является Синтезом: {title}'