From 594d70e9d5ab00f5977824691194cbfcdcd2afab Mon Sep 17 00:00:00 2001 From: Ivan <8611739+IRBorisov@users.noreply.github.com> Date: Sat, 10 Aug 2024 11:41:52 +0300 Subject: [PATCH] M: Propagate cst_update --- .../backend/apps/oss/models/ChangeManager.py | 153 ++++++++++++++---- .../apps/oss/serializers/data_access.py | 4 +- .../tests/s_views/t_change_constituents.py | 31 +++- rsconcept/backend/apps/oss/views/oss.py | 2 +- .../apps/rsform/models/Constituenta.py | 16 +- .../backend/apps/rsform/models/RSForm.py | 48 ++++++ .../backend/apps/rsform/models/__init__.py | 2 +- .../apps/rsform/serializers/__init__.py | 1 + .../apps/rsform/serializers/data_access.py | 45 +++--- .../apps/rsform/tests/s_views/t_rsforms.py | 42 +++-- .../backend/apps/rsform/views/rsforms.py | 29 ++-- rsconcept/frontend/src/models/rsform.ts | 11 +- .../EditorConstituenta/FormConstituenta.tsx | 23 ++- .../src/pages/RSFormPage/RSEditContext.tsx | 4 +- .../ViewConstituents/ViewConstituents.tsx | 2 +- 15 files changed, 308 insertions(+), 105 deletions(-) diff --git a/rsconcept/backend/apps/oss/models/ChangeManager.py b/rsconcept/backend/apps/oss/models/ChangeManager.py index 82f0df80..c006c163 100644 --- a/rsconcept/backend/apps/oss/models/ChangeManager.py +++ b/rsconcept/backend/apps/oss/models/ChangeManager.py @@ -1,16 +1,26 @@ ''' Models: Change propagation manager. ''' from typing import Optional, cast +from cctext import extract_entities + from apps.library.models import LibraryItem from apps.rsform.graph import Graph -from apps.rsform.models import INSERT_LAST, Constituenta, CstType, RSForm +from apps.rsform.models import ( + INSERT_LAST, + Constituenta, + CstType, + RSForm, + extract_globals, + replace_entities, + replace_globals +) from .Inheritance import Inheritance from .Operation import Operation from .OperationSchema import OperationSchema from .Substitution import Substitution -AliasMapping = dict[str, Constituenta] +CstMapping = dict[str, Constituenta] # TODO: add more variety tests for cascade resolutions model @@ -54,12 +64,12 @@ class ChangeManager: self._insert_new(schema) return schema - def get_operation(self, schema: RSForm) -> Optional[Operation]: + def get_operation(self, schema: RSForm) -> Operation: ''' Get operation by schema. ''' for operation in self.operations: if operation.result_id == schema.model.pk: return operation - return None + raise ValueError(f'Operation for schema {schema.model.pk} not found') def ensure_loaded(self) -> None: ''' Ensure propagation of changes. ''' @@ -72,8 +82,12 @@ class ChangeManager: for item in self._oss.inheritance().only('operation_id', 'parent_id', 'child_id'): self.inheritance[item.operation_id].append((item.parent_id, item.child_id)) - def get_successor_for(self, parent_cst: int, operation: int, - ignore_substitution: bool = False) -> Optional[int]: + def get_successor_for( + self, + parent_cst: int, + operation: int, + ignore_substitution: bool = False + ) -> Optional[int]: ''' Get child for parent inside target RSFrom. ''' if not ignore_substitution: for sub in self.substitutions: @@ -102,44 +116,37 @@ class ChangeManager: ''' Trigger cascade resolutions when new constituent is created. ''' self.cache.insert(source) depend_aliases = new_cst.extract_references() - alias_mapping: AliasMapping = {} + alias_mapping: CstMapping = {} for alias in depend_aliases: cst = source.cache.by_alias.get(alias) if cst is not None: alias_mapping[alias] = cst operation = self.cache.get_operation(source) - if operation is None: - return - self._create_cst_cascade(new_cst, operation, alias_mapping) + self._cascade_create_cst(new_cst, operation, alias_mapping) def on_change_cst_type(self, target: Constituenta, source: RSForm) -> None: - ''' Trigger cascade resolutions when new constituent type is changed. ''' + ''' Trigger cascade resolutions when constituenta type is changed. ''' self.cache.insert(source) operation = self.cache.get_operation(source) - if operation is None: - return - self._change_cst_type_cascade(target.pk, target.cst_type, operation) + self._cascade_change_cst_type(target.pk, target.cst_type, operation) - def _change_cst_type_cascade(self, cst_id: int, ctype: CstType, operation: Operation) -> None: + def on_update_cst(self, target: Constituenta, data: dict, old_data: dict, source: RSForm) -> None: + ''' Trigger cascade resolutions when constituenta data is changed. ''' + self.cache.insert(source) + operation = self.cache.get_operation(source) + depend_aliases = self._extract_data_references(data, old_data) + alias_mapping: CstMapping = {} + for alias in depend_aliases: + cst = source.cache.by_alias.get(alias) + if cst is not None: + alias_mapping[alias] = cst + self._cascade_update_cst(target.pk, operation, data, old_data, alias_mapping) + + def _cascade_create_cst(self, prototype: Constituenta, operation: Operation, mapping: CstMapping) -> None: children = self.cache.graph.outputs[operation.pk] if len(children) == 0: return - self.cache.ensure_loaded() - for child_id in children: - child_operation = self.cache.operation_by_id[child_id] - successor_id = self.cache.get_successor_for(cst_id, child_id, ignore_substitution=True) - if successor_id is None: - continue - child_schema = self.cache.get_schema(child_operation) - if child_schema is not None and child_schema.change_cst_type(successor_id, ctype): - self._change_cst_type_cascade(successor_id, ctype, child_operation) - - - def _create_cst_cascade(self, prototype: Constituenta, source: Operation, mapping: AliasMapping) -> None: - children = self.cache.graph.outputs[source.pk] - if len(children) == 0: - return - source_schema = self.cache.get_schema(source) + source_schema = self.cache.get_schema(operation) assert source_schema is not None for child_id in children: child_operation = self.cache.operation_by_id[child_id] @@ -147,6 +154,8 @@ class ChangeManager: if child_schema is None: continue + # TODO: update substitutions for diamond synthesis (if needed) + self.cache.ensure_loaded() new_mapping = self._transform_mapping(mapping, child_operation, child_schema) alias_mapping = {alias: cst.alias for alias, cst in new_mapping.items()} @@ -159,13 +168,58 @@ class ChangeManager: ) self.cache.insert_inheritance(new_inheritance) new_mapping = {alias_mapping[alias]: cst for alias, cst in new_mapping.items()} - self._create_cst_cascade(new_cst, child_operation, new_mapping) + self._cascade_create_cst(new_cst, child_operation, new_mapping) + def _cascade_change_cst_type(self, cst_id: int, ctype: CstType, operation: Operation) -> None: + children = self.cache.graph.outputs[operation.pk] + if len(children) == 0: + return + self.cache.ensure_loaded() + for child_id in children: + child_operation = self.cache.operation_by_id[child_id] + successor_id = self.cache.get_successor_for(cst_id, child_id, ignore_substitution=True) + if successor_id is None: + continue + child_schema = self.cache.get_schema(child_operation) + if child_schema is not None and child_schema.change_cst_type(successor_id, ctype): + self._cascade_change_cst_type(successor_id, ctype, child_operation) - def _transform_mapping(self, mapping: AliasMapping, operation: Operation, schema: RSForm) -> AliasMapping: + # pylint: disable=too-many-arguments + def _cascade_update_cst( + self, + cst_id: int, operation: Operation, + data: dict, old_data: dict, + mapping: CstMapping + ) -> None: + children = self.cache.graph.outputs[operation.pk] + if len(children) == 0: + return + self.cache.ensure_loaded() + for child_id in children: + child_operation = self.cache.operation_by_id[child_id] + successor_id = self.cache.get_successor_for(cst_id, child_id, ignore_substitution=True) + if successor_id is None: + continue + child_schema = self.cache.get_schema(child_operation) + assert child_schema is not None + new_mapping = self._transform_mapping(mapping, child_operation, child_schema) + alias_mapping = {alias: cst.alias for alias, cst in new_mapping.items()} + successor = child_schema.cache.by_id.get(successor_id) + if successor is None: + continue + new_data = self._prepare_update_data(successor, data, old_data, alias_mapping) + if len(new_data) == 0: + continue + new_old_data = child_schema.update_cst(successor, new_data) + if len(new_old_data) == 0: + continue + new_mapping = {alias_mapping[alias]: cst for alias, cst in new_mapping.items()} + self._cascade_update_cst(successor_id, child_operation, new_data, new_old_data, new_mapping) + + def _transform_mapping(self, mapping: CstMapping, operation: Operation, schema: RSForm) -> CstMapping: if len(mapping) == 0: return mapping - result: AliasMapping = {} + result: CstMapping = {} for alias, cst in mapping.items(): successor_id = self.cache.get_successor_for(cst.pk, operation.pk) if successor_id is None: @@ -192,3 +246,34 @@ class ChangeManager: return INSERT_LAST prev_cst = destination.cache.by_id[inherited_prev_id] return cast(int, prev_cst.order) + 1 + + def _extract_data_references(self, data: dict, old_data: dict) -> set[str]: + result: set[str] = set() + if 'definition_formal' in data: + result.update(extract_globals(data['definition_formal'])) + result.update(extract_globals(old_data['definition_formal'])) + if 'term_raw' in data: + result.update(extract_entities(data['term_raw'])) + result.update(extract_entities(old_data['term_raw'])) + if 'definition_raw' in data: + result.update(extract_entities(data['definition_raw'])) + result.update(extract_entities(old_data['definition_raw'])) + return result + + def _prepare_update_data(self, cst: Constituenta, data: dict, old_data: dict, mapping: dict[str, str]) -> dict: + new_data = {} + if 'term_forms' in data: + if old_data['term_forms'] == cst.term_forms: + new_data['term_forms'] = data['term_forms'] + if 'convention' in data: + if old_data['convention'] == cst.convention: + new_data['convention'] = data['convention'] + if 'definition_formal' in data: + new_data['definition_formal'] = replace_globals(data['definition_formal'], mapping) + if 'term_raw' in data: + if replace_entities(old_data['term_raw'], mapping) == cst.term_raw: + new_data['term_raw'] = replace_entities(data['term_raw'], mapping) + if 'definition_raw' in data: + if replace_entities(old_data['definition_raw'], mapping) == cst.definition_raw: + new_data['definition_raw'] = replace_entities(data['definition_raw'], mapping) + return new_data diff --git a/rsconcept/backend/apps/oss/serializers/data_access.py b/rsconcept/backend/apps/oss/serializers/data_access.py index 81bafeaf..3829b8d6 100644 --- a/rsconcept/backend/apps/oss/serializers/data_access.py +++ b/rsconcept/backend/apps/oss/serializers/data_access.py @@ -57,9 +57,9 @@ class OperationCreateSerializer(serializers.Serializer): class OperationUpdateSerializer(serializers.Serializer): - ''' Serializer: Operation creation. ''' + ''' Serializer: Operation update. ''' class OperationUpdateData(serializers.ModelSerializer): - ''' Serializer: Operation creation data. ''' + ''' Serializer: Operation update data. ''' class Meta: ''' serializer metadata. ''' model = Operation diff --git a/rsconcept/backend/apps/oss/tests/s_views/t_change_constituents.py b/rsconcept/backend/apps/oss/tests/s_views/t_change_constituents.py index af04af57..b2b76fb3 100644 --- a/rsconcept/backend/apps/oss/tests/s_views/t_change_constituents.py +++ b/rsconcept/backend/apps/oss/tests/s_views/t_change_constituents.py @@ -58,14 +58,14 @@ class TestChangeConstituents(EndpointTester): self.assertEqual(self.ks3.constituents().count(), 4) - @decl_endpoint('/api/rsforms/{item}/create-cst', method='post') + @decl_endpoint('/api/rsforms/{schema}/create-cst', method='post') def test_create_constituenta(self): data = { 'alias': 'X3', 'cst_type': CstType.BASE, 'definition_formal': 'X4 = X5' } - response = self.executeCreated(data=data, item=self.ks1.model.pk) + response = self.executeCreated(data=data, schema=self.ks1.model.pk) new_cst = Constituenta.objects.get(pk=response.data['new_cst']['id']) inherited_cst = Constituenta.objects.get(as_child__parent_id=new_cst.pk) self.assertEqual(self.ks1.constituents().count(), 3) @@ -74,13 +74,36 @@ class TestChangeConstituents(EndpointTester): self.assertEqual(inherited_cst.order, 3) self.assertEqual(inherited_cst.definition_formal, 'X1 = X2') - @decl_endpoint('/api/rsforms/{item}/rename-cst', method='patch') + @decl_endpoint('/api/rsforms/{schema}/rename-cst', method='patch') def test_rename_constituenta(self): data = {'target': self.ks1X1.pk, 'alias': 'D21', 'cst_type': CstType.TERM} - response = self.executeOK(data=data, item=self.ks1.model.pk) + response = self.executeOK(data=data, schema=self.ks1.model.pk) self.ks1X1.refresh_from_db() inherited_cst = Constituenta.objects.get(as_child__parent_id=self.ks1X1.pk) self.assertEqual(self.ks1X1.alias, data['alias']) self.assertEqual(self.ks1X1.cst_type, data['cst_type']) self.assertEqual(inherited_cst.alias, 'D2') self.assertEqual(inherited_cst.cst_type, data['cst_type']) + + @decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch') + def test_update_constituenta(self): + d2 = self.ks3.insert_new('D2', cst_type=CstType.TERM, definition_raw='@{X1|sing,nomn}') + data = { + 'target': self.ks1X1.pk, + 'item_data': { + 'term_raw': 'Test1', + 'definition_formal': r'X4\X4', + 'definition_raw': '@{X5|sing,datv}' + } + } + response = self.executeOK(data=data, schema=self.ks1.model.pk) + self.ks1X1.refresh_from_db() + d2.refresh_from_db() + inherited_cst = Constituenta.objects.get(as_child__parent_id=self.ks1X1.pk) + self.assertEqual(self.ks1X1.term_raw, data['item_data']['term_raw']) + self.assertEqual(self.ks1X1.definition_formal, data['item_data']['definition_formal']) + self.assertEqual(self.ks1X1.definition_raw, data['item_data']['definition_raw']) + self.assertEqual(d2.definition_resolved, data['item_data']['term_raw']) + self.assertEqual(inherited_cst.term_raw, data['item_data']['term_raw']) + self.assertEqual(inherited_cst.definition_formal, r'X1\X1') + self.assertEqual(inherited_cst.definition_raw, r'@{X2|sing,datv}') diff --git a/rsconcept/backend/apps/oss/views/oss.py b/rsconcept/backend/apps/oss/views/oss.py index dea094b0..5007ebdd 100644 --- a/rsconcept/backend/apps/oss/views/oss.py +++ b/rsconcept/backend/apps/oss/views/oss.py @@ -254,7 +254,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev operation.alias = serializer.validated_data['item_data']['alias'] operation.title = serializer.validated_data['item_data']['title'] operation.comment = serializer.validated_data['item_data']['comment'] - operation.save() + operation.save(update_fields=['alias', 'title', 'comment']) if operation.result is not None: can_edit = permissions.can_edit_item(request.user, operation.result) diff --git a/rsconcept/backend/apps/rsform/models/Constituenta.py b/rsconcept/backend/apps/rsform/models/Constituenta.py index 16375ac9..6ea896f2 100644 --- a/rsconcept/backend/apps/rsform/models/Constituenta.py +++ b/rsconcept/backend/apps/rsform/models/Constituenta.py @@ -26,6 +26,16 @@ def extract_globals(expression: str) -> set[str]: return set(re.findall(_RE_GLOBALS, expression)) +def replace_globals(expression: str, mapping: dict[str, str]) -> str: + ''' Replace all global aliases in expression. ''' + return apply_pattern(expression, mapping, _GLOBAL_ID_PATTERN) + + +def replace_entities(expression: str, mapping: dict[str, str]) -> str: + ''' Replace all entity references in expression. ''' + return apply_pattern(expression, mapping, _REF_ENTITY_PATTERN) + + class CstType(TextChoices): ''' Type of constituenta. ''' BASE = 'basic' @@ -114,15 +124,15 @@ class Constituenta(Model): if change_aliases and self.alias in mapping: modified = True self.alias = mapping[self.alias] - expression = apply_pattern(self.definition_formal, mapping, _GLOBAL_ID_PATTERN) + expression = replace_globals(self.definition_formal, mapping) if expression != self.definition_formal: modified = True self.definition_formal = expression - term = apply_pattern(self.term_raw, mapping, _REF_ENTITY_PATTERN) + term = replace_entities(self.term_raw, mapping) if term != self.term_raw: modified = True self.term_raw = term - definition = apply_pattern(self.definition_raw, mapping, _REF_ENTITY_PATTERN) + definition = replace_entities(self.definition_raw, mapping) if definition != self.definition_raw: modified = True self.definition_raw = definition diff --git a/rsconcept/backend/apps/rsform/models/RSForm.py b/rsconcept/backend/apps/rsform/models/RSForm.py index 03fcdfa9..817c27e1 100644 --- a/rsconcept/backend/apps/rsform/models/RSForm.py +++ b/rsconcept/backend/apps/rsform/models/RSForm.py @@ -273,6 +273,54 @@ class RSForm: self.save() return result + # pylint: disable=too-many-branches + def update_cst(self, target: Constituenta, data: dict) -> dict: + ''' Update persistent attributes of a given constituenta. Return old values. ''' + self.cache.ensure_loaded() + cst = self.cache.by_id.get(target.pk) + if cst is None: + raise ValidationError(msg.constituentaNotInRSform(target.alias)) + + old_data = {} + term_changed = False + if 'convention' in data: + cst.convention = data['convention'] + if 'definition_formal' in data: + if cst.definition_formal == data['definition_formal']: + del data['definition_formal'] + else: + old_data['definition_formal'] = cst.definition_formal + cst.definition_formal = data['definition_formal'] + if 'term_forms' in data: + term_changed = True + old_data['term_forms'] = cst.term_forms + cst.term_forms = data['term_forms'] + if 'definition_raw' in data or 'term_raw' in data: + resolver = self.resolver() + if 'term_raw' in data: + if cst.term_raw == data['term_raw']: + del data['term_raw'] + else: + term_changed = True + old_data['term_raw'] = cst.term_raw + cst.term_raw = data['term_raw'] + cst.term_resolved = resolver.resolve(cst.term_raw) + if 'term_forms' not in data: + cst.term_forms = [] + resolver.context[cst.alias] = Entity(cst.alias, cst.term_resolved, manual_forms=cst.term_forms) + if 'definition_raw' in data: + if cst.definition_raw == data['definition_raw']: + del data['definition_raw'] + else: + old_data['definition_raw'] = cst.definition_raw + cst.definition_raw = data['definition_raw'] + cst.definition_resolved = resolver.resolve(cst.definition_raw) + cst.save() + if term_changed: + self.on_term_change([cst.pk]) + self.save() + return old_data + def move_cst(self, target: list[Constituenta], destination: int) -> None: ''' Move list of constituents to specific position ''' count_moved = 0 diff --git a/rsconcept/backend/apps/rsform/models/__init__.py b/rsconcept/backend/apps/rsform/models/__init__.py index 481d7a84..f091858f 100644 --- a/rsconcept/backend/apps/rsform/models/__init__.py +++ b/rsconcept/backend/apps/rsform/models/__init__.py @@ -1,4 +1,4 @@ ''' Django: Models. ''' -from .Constituenta import Constituenta, CstType, extract_globals +from .Constituenta import Constituenta, CstType, extract_globals, replace_entities, replace_globals from .RSForm import INSERT_LAST, RSForm diff --git a/rsconcept/backend/apps/rsform/serializers/__init__.py b/rsconcept/backend/apps/rsform/serializers/__init__.py index b0097436..5916b6ae 100644 --- a/rsconcept/backend/apps/rsform/serializers/__init__.py +++ b/rsconcept/backend/apps/rsform/serializers/__init__.py @@ -17,6 +17,7 @@ from .data_access import ( CstSerializer, CstSubstituteSerializer, CstTargetSerializer, + CstUpdateSerializer, InlineSynthesisSerializer, RSFormParseSerializer, RSFormSerializer, diff --git a/rsconcept/backend/apps/rsform/serializers/data_access.py b/rsconcept/backend/apps/rsform/serializers/data_access.py index af9a309d..6acef07d 100644 --- a/rsconcept/backend/apps/rsform/serializers/data_access.py +++ b/rsconcept/backend/apps/rsform/serializers/data_access.py @@ -1,5 +1,5 @@ ''' Serializers for persistent data manipulation. ''' -from typing import Optional, cast +from typing import cast from django.contrib.auth.models import User from django.core.exceptions import PermissionDenied @@ -38,25 +38,30 @@ class CstSerializer(serializers.ModelSerializer): fields = '__all__' read_only_fields = ('id', 'schema', 'order', 'alias', 'cst_type', 'definition_resolved', 'term_resolved') - def update(self, instance: Constituenta, validated_data) -> Constituenta: - data = validated_data # Note: use alias for better code readability - definition: Optional[str] = data['definition_raw'] if 'definition_raw' in data else None - term: Optional[str] = data['term_raw'] if 'term_raw' in data else None - term_changed = 'term_forms' in data - schema = RSForm(instance.schema) - if definition is not None and definition != instance.definition_raw: - data['definition_resolved'] = schema.resolver().resolve(definition) - if term is not None and term != instance.term_raw: - data['term_resolved'] = schema.resolver().resolve(term) - if data['term_resolved'] != instance.term_resolved and 'term_forms' not in data: - data['term_forms'] = [] - term_changed = data['term_resolved'] != instance.term_resolved - result: Constituenta = super().update(instance, data) - if term_changed: - schema.on_term_change([result.pk]) - result.refresh_from_db() - schema.save() - return result + +class CstUpdateSerializer(serializers.Serializer): + ''' Serializer: Constituenta update. ''' + class ConstituentaUpdateData(serializers.ModelSerializer): + ''' Serializer: Operation creation data. ''' + class Meta: + ''' serializer metadata. ''' + model = Constituenta + fields = 'convention', 'definition_formal', 'definition_raw', 'term_raw', 'term_forms' + + target = PKField( + many=False, + queryset=Constituenta.objects.all().only('convention', 'definition_formal', 'definition_raw', 'term_raw') + ) + item_data = ConstituentaUpdateData() + + def validate(self, attrs): + schema = cast(LibraryItem, self.context['schema']) + cst = cast(Constituenta, attrs['target']) + if schema and cst.schema_id != schema.pk: + raise serializers.ValidationError({ + f'{cst.pk}': msg.constituentaNotInRSform(schema.title) + }) + return attrs class CstDetailsSerializer(serializers.ModelSerializer): 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 86339300..ae429bfa 100644 --- a/rsconcept/backend/apps/rsform/tests/s_views/t_rsforms.py +++ b/rsconcept/backend/apps/rsform/tests/s_views/t_rsforms.py @@ -525,7 +525,7 @@ class TestConstituentaAPI(EndpointTester): @decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch') def test_partial_update(self): - data = {'id': self.cst1.pk, 'convention': 'tt'} + data = {'target': self.cst1.pk, 'item_data': {'convention': 'tt'}} self.executeForbidden(data=data, schema=self.rsform_unowned.model.pk) self.logout() @@ -543,9 +543,11 @@ class TestConstituentaAPI(EndpointTester): @decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch') def test_update_resolved_no_refs(self): data = { - 'id': self.cst3.pk, - 'term_raw': 'New term', - 'definition_raw': 'New def' + 'target': self.cst3.pk, + 'item_data': { + 'term_raw': 'New term', + 'definition_raw': 'New def' + } } response = self.executeOK(data=data, schema=self.rsform_owned.model.pk) self.cst3.refresh_from_db() @@ -558,9 +560,11 @@ class TestConstituentaAPI(EndpointTester): @decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch') def test_update_resolved_refs(self): data = { - 'id': self.cst3.pk, - 'term_raw': '@{X1|nomn,sing}', - 'definition_raw': '@{X1|nomn,sing} @{X1|sing,datv}' + 'target': self.cst3.pk, + 'item_data': { + 'term_raw': '@{X1|nomn,sing}', + 'definition_raw': '@{X1|nomn,sing} @{X1|sing,datv}' + } } response = self.executeOK(data=data, schema=self.rsform_owned.model.pk) self.cst3.refresh_from_db() @@ -569,13 +573,31 @@ class TestConstituentaAPI(EndpointTester): self.assertEqual(self.cst3.definition_resolved, f'{self.cst1.term_resolved} form1') self.assertEqual(response.data['definition_resolved'], f'{self.cst1.term_resolved} form1') + @decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch') + def test_update_term_forms(self): + data = { + 'target': self.cst3.pk, + 'item_data': { + 'definition_raw': '@{X3|sing,datv}', + 'term_forms': [{'text': 'form1', 'tags': 'sing,datv'}] + } + } + response = self.executeOK(data=data, schema=self.rsform_owned.model.pk) + self.cst3.refresh_from_db() + self.assertEqual(self.cst3.definition_resolved, 'form1') + self.assertEqual(response.data['definition_resolved'], 'form1') + self.assertEqual(self.cst3.term_forms, data['item_data']['term_forms']) + self.assertEqual(response.data['term_forms'], data['item_data']['term_forms']) + @decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch') def test_readonly_cst_fields(self): data = { - 'id': self.cst1.pk, - 'alias': 'X33', - 'order': 10 + 'target': self.cst1.pk, + 'item_data': { + 'alias': 'X33', + 'order': 10 + } } response = self.executeOK(data=data, schema=self.rsform_owned.model.pk) self.assertEqual(response.data['alias'], 'X1') diff --git a/rsconcept/backend/apps/rsform/views/rsforms.py b/rsconcept/backend/apps/rsform/views/rsforms.py index 816a1e35..e1844729 100644 --- a/rsconcept/backend/apps/rsform/views/rsforms.py +++ b/rsconcept/backend/apps/rsform/views/rsforms.py @@ -41,14 +41,14 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr if self.action in [ 'load_trs', 'create_cst', - 'delete_multiple_cst', 'rename_cst', + 'update_cst', 'move_cst', + 'delete_multiple_cst', 'substitute', 'restore_order', 'reset_aliases', 'produce_structure', - 'update_cst' ]: permission_list = [permissions.ItemEditor] elif self.action in [ @@ -85,8 +85,8 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr else: insert_after = data['insert_after'] + schema = m.RSForm(self._get_item()) with transaction.atomic(): - schema = m.RSForm(self._get_item()) new_cst = schema.create_cst(data, insert_after) hosts = LibraryItem.objects.filter(operations__result=schema.model) for host in hosts: @@ -104,7 +104,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr @extend_schema( summary='update persistent attributes of a given constituenta', tags=['RSForm'], - request=s.CstSerializer, + request=s.CstUpdateSerializer, responses={ c.HTTP_200_OK: s.CstSerializer, c.HTTP_400_BAD_REQUEST: None, @@ -115,23 +115,22 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr @action(detail=True, methods=['patch'], url_path='update-cst') def update_cst(self, request: Request, pk) -> HttpResponse: ''' Update persistent attributes of a given constituenta. ''' - schema = self._get_item() - serializer = s.CstSerializer(data=request.data, partial=True) + model = self._get_item() + serializer = s.CstUpdateSerializer(data=request.data, partial=True, context={'schema': model}) serializer.is_valid(raise_exception=True) - cst = m.Constituenta.objects.get(pk=request.data['id']) - if cst.schema != schema: - raise ValidationError({ - 'schema': msg.constituentaNotInRSform(schema.title) - }) - + cst = cast(m.Constituenta, serializer.validated_data['target']) + schema = m.RSForm(model) + data = serializer.validated_data['item_data'] with transaction.atomic(): - serializer.update(instance=cst, validated_data=serializer.validated_data) - # 'convention' | 'definition_formal' | 'definition_raw' | 'term_raw' | 'term_forms' + hosts = LibraryItem.objects.filter(operations__result=model) + old_data = schema.update_cst(cst, data) + for host in hosts: + ChangeManager(host).on_update_cst(cst, data, old_data, schema) return Response( status=c.HTTP_200_OK, - data=s.CstSerializer(cst).data + data=s.CstSerializer(m.Constituenta.objects.get(pk=request.data['target'])).data ) @extend_schema( diff --git a/rsconcept/frontend/src/models/rsform.ts b/rsconcept/frontend/src/models/rsform.ts index 396e9e2f..10b31dae 100644 --- a/rsconcept/frontend/src/models/rsform.ts +++ b/rsconcept/frontend/src/models/rsform.ts @@ -152,11 +152,12 @@ export interface ICstMovetoData extends IConstituentaList { /** * Represents data, used in updating persistent attributes in {@link IConstituenta}. */ -export interface ICstUpdateData - extends Pick, - Partial< - Pick - > {} +export interface ICstUpdateData { + target: ConstituentaID; + item_data: Partial< + Pick + >; +} /** * Represents data, used in renaming {@link IConstituenta}. diff --git a/rsconcept/frontend/src/pages/RSFormPage/EditorConstituenta/FormConstituenta.tsx b/rsconcept/frontend/src/pages/RSFormPage/EditorConstituenta/FormConstituenta.tsx index ace20054..3c977fb4 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/EditorConstituenta/FormConstituenta.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/EditorConstituenta/FormConstituenta.tsx @@ -114,12 +114,21 @@ function FormConstituenta({ return; } const data: ICstUpdateData = { - id: state.id, - term_raw: term, - definition_formal: expression, - definition_raw: textDefinition, - convention: convention + target: state.id, + item_data: {} }; + if (state.term_raw !== term) { + data.item_data.term_raw = term; + } + if (state.definition_formal !== expression) { + data.item_data.definition_formal = expression; + } + if (state.definition_raw !== textDefinition) { + data.item_data.definition_raw = textDefinition; + } + if (state.convention !== convention) { + data.item_data.convention = convention; + } cstUpdate(data, () => toast.success(information.changesSaved)); } @@ -216,7 +225,7 @@ function FormConstituenta({ onChange={event => setConvention(event.target.value)} /> - {!showConvention && (!disabled || processing) ? ( + - ) : null} + {!disabled || processing ? (
diff --git a/rsconcept/frontend/src/pages/RSFormPage/RSEditContext.tsx b/rsconcept/frontend/src/pages/RSFormPage/RSEditContext.tsx index f354bdca..01e634ff 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/RSEditContext.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/RSEditContext.tsx @@ -323,8 +323,8 @@ export const RSEditState = ({ return; } const data: ICstUpdateData = { - id: activeCst.id, - term_forms: forms + target: activeCst.id, + item_data: { term_forms: forms } }; model.cstUpdate(data, () => toast.success(information.changesSaved)); }, diff --git a/rsconcept/frontend/src/pages/RSFormPage/ViewConstituents/ViewConstituents.tsx b/rsconcept/frontend/src/pages/RSFormPage/ViewConstituents/ViewConstituents.tsx index 46ce3211..0e435ac2 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/ViewConstituents/ViewConstituents.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/ViewConstituents/ViewConstituents.tsx @@ -57,7 +57,7 @@ function ViewConstituents({ expression, schema, activeCst, isBottom, onOpenEdit className={clsx( 'border overflow-visible', // prettier: split-lines { - 'mt-[2.2rem] rounded-l-md rounded-r-none': !isBottom, + 'mt-[2.2rem] rounded-l-md rounded-r-none h-fit': !isBottom, 'mt-3 mx-6 rounded-md md:w-[45.8rem]': isBottom } )}