diff --git a/rsconcept/backend/apps/library/models/LibraryItem.py b/rsconcept/backend/apps/library/models/LibraryItem.py index 9cb71709..3b1762a3 100644 --- a/rsconcept/backend/apps/library/models/LibraryItem.py +++ b/rsconcept/backend/apps/library/models/LibraryItem.py @@ -1,7 +1,6 @@ ''' Models: LibraryItem. ''' import re -from django.db import transaction from django.db.models import ( SET_NULL, BooleanField, @@ -16,7 +15,6 @@ from django.db.models import ( from apps.users.models import User -from .Subscription import Subscription from .Version import Version @@ -125,34 +123,3 @@ class LibraryItem(Model): def versions(self) -> QuerySet[Version]: ''' Get all Versions of this item. ''' return Version.objects.filter(item=self.pk).order_by('-time_create') - - # TODO: move to View layer - @transaction.atomic - def save(self, *args, **kwargs): - ''' Save updating subscriptions and connected operations. ''' - if not self._state.adding: - self._update_connected_operations() - subscribe = self._state.adding and self.owner - super().save(*args, **kwargs) - if subscribe: - Subscription.subscribe(user=self.owner_id, item=self.pk) - - def _update_connected_operations(self): - # using method level import to prevent circular dependency - from apps.oss.models import Operation # pylint: disable=import-outside-toplevel - operations = Operation.objects.filter(result__pk=self.pk) - if not operations.exists(): - return - for operation in operations: - changed = False - if operation.alias != self.alias: - operation.alias = self.alias - changed = True - if operation.title != self.title: - operation.title = self.title - changed = True - if operation.comment != self.comment: - operation.comment = self.comment - changed = True - if changed: - operation.save() diff --git a/rsconcept/backend/apps/library/tests/s_models/t_LibraryItem.py b/rsconcept/backend/apps/library/tests/s_models/t_LibraryItem.py index 2935a8f3..bb5daecf 100644 --- a/rsconcept/backend/apps/library/tests/s_models/t_LibraryItem.py +++ b/rsconcept/backend/apps/library/tests/s_models/t_LibraryItem.py @@ -68,7 +68,6 @@ class TestLibraryItem(TestCase): self.assertEqual(item.alias, 'KS1') self.assertEqual(item.comment, 'Test comment') self.assertEqual(item.location, LocationHead.COMMON) - self.assertTrue(Subscription.objects.filter(user=item.owner, item=item).exists()) class TestLocation(TestCase): diff --git a/rsconcept/backend/apps/library/tests/s_models/t_Subscription.py b/rsconcept/backend/apps/library/tests/s_models/t_Subscription.py index b9a005d2..ef0d7b3f 100644 --- a/rsconcept/backend/apps/library/tests/s_models/t_Subscription.py +++ b/rsconcept/backend/apps/library/tests/s_models/t_Subscription.py @@ -21,9 +21,7 @@ class TestSubscription(TestCase): def test_default(self): subs = list(Subscription.objects.filter(item=self.item)) - self.assertEqual(len(subs), 1) - self.assertEqual(subs[0].item, self.item) - self.assertEqual(subs[0].user, self.user1) + self.assertEqual(len(subs), 0) def test_str(self): 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 4de0b9dd..a52d54e1 100644 --- a/rsconcept/backend/apps/library/tests/s_views/t_library.py +++ b/rsconcept/backend/apps/library/tests/s_views/t_library.py @@ -49,6 +49,7 @@ class TestLibraryViewset(EndpointTester): self.assertEqual(response.data['item_type'], LibraryItemType.RSFORM) self.assertEqual(response.data['title'], data['title']) self.assertEqual(response.data['alias'], data['alias']) + self.assertTrue(Subscription.objects.filter(user=self.user, item_id=response.data['id']).exists()) data = { 'item_type': LibraryItemType.OPERATION_SCHEMA, @@ -74,7 +75,7 @@ class TestLibraryViewset(EndpointTester): @decl_endpoint('/api/library/{item}', method='patch') def test_update(self): - data = {'id': self.unowned.pk, 'title': 'New Title'} + data = {'title': 'New Title'} self.executeNotFound(data=data, item=self.invalid_item) self.executeForbidden(data=data, item=self.unowned.pk) @@ -86,13 +87,12 @@ class TestLibraryViewset(EndpointTester): self.unowned.save() self.executeForbidden(data=data, item=self.unowned.pk) - data = {'id': self.owned.pk, 'title': 'New Title'} + data = {'title': 'New Title'} response = self.executeOK(data=data, item=self.owned.pk) self.assertEqual(response.data['title'], data['title']) self.assertEqual(response.data['alias'], self.owned.alias) data = { - 'id': self.owned.pk, 'title': 'Another Title', 'owner': self.user2.pk, 'access_policy': AccessPolicy.PROTECTED, diff --git a/rsconcept/backend/apps/library/views/library.py b/rsconcept/backend/apps/library/views/library.py index 85f2c7b2..7a77f9f2 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 OperationSchema +from apps.oss.models import Operation, OperationSchema from apps.rsform.models import RSForm from apps.rsform.serializers import RSFormParseSerializer from apps.users.models import User @@ -37,11 +37,35 @@ class LibraryViewSet(viewsets.ModelViewSet): return s.LibraryItemBaseSerializer return s.LibraryItemSerializer - def perform_create(self, serializer): + def perform_create(self, serializer) -> None: if not self.request.user.is_anonymous and 'owner' not in self.request.POST: - return serializer.save(owner=self.request.user) + instance = serializer.save(owner=self.request.user) else: - return serializer.save() + instance = serializer.save() + if instance.owner: + m.Subscription.subscribe(user=instance.owner_id, item=instance.pk) + + def perform_update(self, serializer) -> None: + instance = serializer.save() + operations = Operation.objects.filter(result__pk=instance.pk) + if not operations.exists(): + return + update_list: list[Operation] = [] + for operation in operations: + changed = False + if operation.alias != instance.alias: + operation.alias = instance.alias + changed = True + if operation.title != instance.title: + operation.title = instance.title + changed = True + if operation.comment != instance.comment: + operation.comment = instance.comment + changed = True + if changed: + update_list.append(operation) + if update_list: + Operation.objects.bulk_update(update_list, ['alias', 'title', 'comment']) def get_permissions(self): if self.action in ['update', 'partial_update']: diff --git a/rsconcept/backend/apps/oss/models/OperationSchema.py b/rsconcept/backend/apps/oss/models/OperationSchema.py index b217a718..1fb4943f 100644 --- a/rsconcept/backend/apps/oss/models/OperationSchema.py +++ b/rsconcept/backend/apps/oss/models/OperationSchema.py @@ -4,7 +4,7 @@ from typing import Optional from django.db.models import QuerySet from apps.library.models import Editor, LibraryItem, LibraryItemType -from apps.rsform.models import RSForm +from apps.rsform.models import Constituenta, RSForm from .Argument import Argument from .Inheritance import Inheritance @@ -186,10 +186,12 @@ class OperationSchema: parents[cst.pk] = items[i] children[items[i].pk] = cst + translated_substitutions: list[tuple[Constituenta, Constituenta]] = [] for sub in substitutions: original = children[sub.original.pk] replacement = children[sub.substitution.pk] - receiver.substitute(original, replacement) + translated_substitutions.append((original, replacement)) + receiver.substitute(translated_substitutions) # TODO: remove duplicates from diamond 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 7e875482..837e7f44 100644 --- a/rsconcept/backend/apps/oss/tests/s_models/t_Operation.py +++ b/rsconcept/backend/apps/oss/tests/s_models/t_Operation.py @@ -31,36 +31,3 @@ class TestOperation(TestCase): self.assertEqual(self.operation.comment, '') self.assertEqual(self.operation.position_x, 0) self.assertEqual(self.operation.position_y, 0) - - - def test_sync_from_result(self): - schema = RSForm.create(alias=self.operation.alias) - self.operation.result = schema.model - self.operation.save() - - schema.model.alias = 'KS2' - schema.model.comment = 'Comment' - schema.model.title = 'Title' - schema.save() - self.operation.refresh_from_db() - - self.assertEqual(self.operation.result, schema.model) - self.assertEqual(self.operation.alias, schema.model.alias) - self.assertEqual(self.operation.title, schema.model.title) - self.assertEqual(self.operation.comment, schema.model.comment) - - def test_sync_from_library_item(self): - schema = LibraryItem.objects.create(alias=self.operation.alias, item_type=LibraryItemType.RSFORM) - self.operation.result = schema - self.operation.save() - - schema.alias = 'KS2' - schema.comment = 'Comment' - schema.title = 'Title' - schema.save() - self.operation.refresh_from_db() - - self.assertEqual(self.operation.result, schema) - self.assertEqual(self.operation.alias, schema.alias) - self.assertEqual(self.operation.title, schema.title) - self.assertEqual(self.operation.comment, schema.comment) diff --git a/rsconcept/backend/apps/oss/tests/s_views/t_change_attributes.py b/rsconcept/backend/apps/oss/tests/s_views/t_change_attributes.py index 5007616e..280165a8 100644 --- a/rsconcept/backend/apps/oss/tests/s_views/t_change_attributes.py +++ b/rsconcept/backend/apps/oss/tests/s_views/t_change_attributes.py @@ -123,3 +123,33 @@ class TestChangeAttributes(EndpointTester): self.assertEqual(list(self.ks1.model.editors()), [self.user, self.user2]) self.assertEqual(list(self.ks2.model.editors()), []) self.assertEqual(set(self.ks3.editors()), set([self.user, self.user3])) + + @decl_endpoint('/api/library/{item}', method='patch') + def test_sync_from_result(self): + data = {'alias': 'KS111', 'title': 'New Title', 'comment': 'New Comment'} + + self.executeOK(data=data, item=self.ks1.model.pk) + self.operation1.refresh_from_db() + + self.assertEqual(self.operation1.result, self.ks1.model) + self.assertEqual(self.operation1.alias, data['alias']) + self.assertEqual(self.operation1.title, data['title']) + self.assertEqual(self.operation1.comment, data['comment']) + + @decl_endpoint('/api/oss/{item}/update-operation', method='patch') + def test_sync_from_operation(self): + data = { + 'target': self.operation3.pk, + 'item_data': { + 'alias': 'Test3 mod', + 'title': 'Test title mod', + 'comment': 'Comment mod' + }, + 'positions': [], + } + + response = self.executeOK(data=data, item=self.owned_id) + self.ks3.refresh_from_db() + self.assertEqual(self.ks3.alias, data['item_data']['alias']) + self.assertEqual(self.ks3.title, data['item_data']['title']) + self.assertEqual(self.ks3.comment, data['item_data']['comment']) diff --git a/rsconcept/backend/apps/rsform/models/RSForm.py b/rsconcept/backend/apps/rsform/models/RSForm.py index f0ca51c3..8af2bb08 100644 --- a/rsconcept/backend/apps/rsform/models/RSForm.py +++ b/rsconcept/backend/apps/rsform/models/RSForm.py @@ -79,14 +79,14 @@ class RSForm: def remove(self, target: Constituenta) -> None: if self.is_loaded: - self.constituents.remove(target) + self.constituents.remove(self.by_id[target.pk]) del self.by_id[target.pk] del self.by_alias[target.alias] def remove_multi(self, target: Iterable[Constituenta]) -> None: if self.is_loaded: for cst in target: - self.constituents.remove(cst) + self.constituents.remove(self.by_id[cst.pk]) del self.by_id[cst.pk] del self.by_alias[cst.alias] @@ -306,18 +306,20 @@ class RSForm: self.resolve_all_text() self.save() - def substitute( - self, - original: Constituenta, - substitution: Constituenta - ) -> None: + def substitute(self, substitutions: list[tuple[Constituenta, Constituenta]]) -> None: ''' Execute constituenta substitution. ''' - assert original.pk != substitution.pk - mapping = {original.alias: substitution.alias} + mapping = {} + deleted: list[Constituenta] = [] + replacements: list[Constituenta] = [] + for original, substitution in substitutions: + assert original.pk != substitution.pk + mapping[original.alias] = substitution.alias + deleted.append(original) + replacements.append(substitution) + self.cache.remove_multi(deleted) + Constituenta.objects.filter(pk__in=[cst.pk for cst in deleted]).delete() self.apply_mapping(mapping) - self.cache.remove(self.cache.by_id[original.pk]) - original.delete() - self.on_term_change([substitution.pk]) + self.on_term_change([substitution.pk for substitution in replacements]) def restore_order(self) -> None: ''' Restore order based on types and term graph. ''' diff --git a/rsconcept/backend/apps/rsform/tests/s_models/t_RSForm.py b/rsconcept/backend/apps/rsform/tests/s_models/t_RSForm.py index c65696b0..a71854cd 100644 --- a/rsconcept/backend/apps/rsform/tests/s_models/t_RSForm.py +++ b/rsconcept/backend/apps/rsform/tests/s_models/t_RSForm.py @@ -207,7 +207,7 @@ class TestRSForm(DBTester): definition_formal=x1.alias ) - self.schema.substitute(x1, x2) + self.schema.substitute([(x1, x2)]) x2.refresh_from_db() d1.refresh_from_db() self.assertEqual(self.schema.constituents().count(), 2) diff --git a/rsconcept/backend/apps/rsform/tests/s_views/t_rsforms.py b/rsconcept/backend/apps/rsform/tests/s_views/t_rsforms.py index 152d497b..a5fb9103 100644 --- a/rsconcept/backend/apps/rsform/tests/s_views/t_rsforms.py +++ b/rsconcept/backend/apps/rsform/tests/s_views/t_rsforms.py @@ -100,7 +100,7 @@ class TestRSFormViewset(EndpointTester): self.assertEqual(response.data['items'][1]['id'], x2.pk) self.assertEqual(response.data['items'][1]['term_raw'], x2.term_raw) self.assertEqual(response.data['items'][1]['term_resolved'], x2.term_resolved) - self.assertEqual(response.data['subscribers'], [self.user.pk]) + self.assertEqual(response.data['subscribers'], []) self.assertEqual(response.data['editors'], []) self.assertEqual(response.data['inheritance'], []) self.assertEqual(response.data['oss'], []) diff --git a/rsconcept/backend/apps/rsform/views/rsforms.py b/rsconcept/backend/apps/rsform/views/rsforms.py index 1230faab..a681fe1b 100644 --- a/rsconcept/backend/apps/rsform/views/rsforms.py +++ b/rsconcept/backend/apps/rsform/views/rsforms.py @@ -226,10 +226,12 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr serializer.is_valid(raise_exception=True) with transaction.atomic(): + substitutions: list[tuple[m.Constituenta, m.Constituenta]] = [] for substitution in serializer.validated_data['substitutions']: original = cast(m.Constituenta, substitution['original']) replacement = cast(m.Constituenta, substitution['substitution']) - m.RSForm(schema).substitute(original, replacement) + substitutions.append((original, replacement)) + m.RSForm(schema).substitute(substitutions) schema.refresh_from_db() return Response( @@ -574,6 +576,7 @@ def inline_synthesis(request: Request) -> HttpResponse: with transaction.atomic(): new_items = receiver.insert_copy(items) + substitutions: list[tuple[m.Constituenta, m.Constituenta]] = [] for substitution in serializer.validated_data['substitutions']: original = cast(m.Constituenta, substitution['original']) replacement = cast(m.Constituenta, substitution['substitution']) @@ -583,7 +586,8 @@ def inline_synthesis(request: Request) -> HttpResponse: else: index = next(i for (i, cst) in enumerate(items) if cst.pk == replacement.pk) replacement = new_items[index] - receiver.substitute(original, replacement) + substitutions.append((original, replacement)) + receiver.substitute(substitutions) receiver.restore_order() return Response( diff --git a/rsconcept/backend/apps/users/tests/t_views.py b/rsconcept/backend/apps/users/tests/t_views.py index d289cc91..2cab0dd1 100644 --- a/rsconcept/backend/apps/users/tests/t_views.py +++ b/rsconcept/backend/apps/users/tests/t_views.py @@ -134,7 +134,6 @@ class TestUserUserProfileAPIView(EndpointTester): def test_password_reset_request(self): self.executeBadData({'email': 'invalid@mail.ru'}) self.executeOK({'email': self.user.email}) - # TODO: check if mail server actually sent email and if reset procedure works class TestSignupAPIView(EndpointTester): diff --git a/rsconcept/frontend/src/models/FolderTree.test.ts b/rsconcept/frontend/src/models/FolderTree.test.ts index 122ad08b..0707f580 100644 --- a/rsconcept/frontend/src/models/FolderTree.test.ts +++ b/rsconcept/frontend/src/models/FolderTree.test.ts @@ -1,7 +1,5 @@ import { FolderTree } from './FolderTree'; -// TODO: test FolderNode and FolderTree exhaustively - describe('Testing Tree construction', () => { test('empty Tree should be empty', () => { const tree = new FolderTree();