M: Propagate cst_update

This commit is contained in:
Ivan 2024-08-10 11:41:52 +03:00
parent e530afd623
commit 594d70e9d5
15 changed files with 308 additions and 105 deletions

View File

@ -1,16 +1,26 @@
''' Models: Change propagation manager. ''' ''' Models: Change propagation manager. '''
from typing import Optional, cast from typing import Optional, cast
from cctext import extract_entities
from apps.library.models import LibraryItem from apps.library.models import LibraryItem
from apps.rsform.graph import Graph 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 .Inheritance import Inheritance
from .Operation import Operation from .Operation import Operation
from .OperationSchema import OperationSchema from .OperationSchema import OperationSchema
from .Substitution import Substitution from .Substitution import Substitution
AliasMapping = dict[str, Constituenta] CstMapping = dict[str, Constituenta]
# TODO: add more variety tests for cascade resolutions model # TODO: add more variety tests for cascade resolutions model
@ -54,12 +64,12 @@ class ChangeManager:
self._insert_new(schema) self._insert_new(schema)
return schema return schema
def get_operation(self, schema: RSForm) -> Optional[Operation]: def get_operation(self, schema: RSForm) -> Operation:
''' Get operation by schema. ''' ''' Get operation by schema. '''
for operation in self.operations: for operation in self.operations:
if operation.result_id == schema.model.pk: if operation.result_id == schema.model.pk:
return operation return operation
return None raise ValueError(f'Operation for schema {schema.model.pk} not found')
def ensure_loaded(self) -> None: def ensure_loaded(self) -> None:
''' Ensure propagation of changes. ''' ''' Ensure propagation of changes. '''
@ -72,8 +82,12 @@ class ChangeManager:
for item in self._oss.inheritance().only('operation_id', 'parent_id', 'child_id'): 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)) self.inheritance[item.operation_id].append((item.parent_id, item.child_id))
def get_successor_for(self, parent_cst: int, operation: int, def get_successor_for(
ignore_substitution: bool = False) -> Optional[int]: self,
parent_cst: int,
operation: int,
ignore_substitution: bool = False
) -> Optional[int]:
''' Get child for parent inside target RSFrom. ''' ''' Get child for parent inside target RSFrom. '''
if not ignore_substitution: if not ignore_substitution:
for sub in self.substitutions: for sub in self.substitutions:
@ -102,44 +116,37 @@ class ChangeManager:
''' Trigger cascade resolutions when new constituent is created. ''' ''' Trigger cascade resolutions when new constituent is created. '''
self.cache.insert(source) self.cache.insert(source)
depend_aliases = new_cst.extract_references() depend_aliases = new_cst.extract_references()
alias_mapping: AliasMapping = {} alias_mapping: CstMapping = {}
for alias in depend_aliases: for alias in depend_aliases:
cst = source.cache.by_alias.get(alias) cst = source.cache.by_alias.get(alias)
if cst is not None: if cst is not None:
alias_mapping[alias] = cst alias_mapping[alias] = cst
operation = self.cache.get_operation(source) operation = self.cache.get_operation(source)
if operation is None: self._cascade_create_cst(new_cst, operation, alias_mapping)
return
self._create_cst_cascade(new_cst, operation, alias_mapping)
def on_change_cst_type(self, target: Constituenta, source: RSForm) -> None: 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) self.cache.insert(source)
operation = self.cache.get_operation(source) operation = self.cache.get_operation(source)
if operation is None: self._cascade_change_cst_type(target.pk, target.cst_type, operation)
return
self._change_cst_type_cascade(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] children = self.cache.graph.outputs[operation.pk]
if len(children) == 0: if len(children) == 0:
return return
self.cache.ensure_loaded() source_schema = self.cache.get_schema(operation)
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)
assert source_schema is not None assert source_schema is not None
for child_id in children: for child_id in children:
child_operation = self.cache.operation_by_id[child_id] child_operation = self.cache.operation_by_id[child_id]
@ -147,6 +154,8 @@ class ChangeManager:
if child_schema is None: if child_schema is None:
continue continue
# TODO: update substitutions for diamond synthesis (if needed)
self.cache.ensure_loaded() self.cache.ensure_loaded()
new_mapping = self._transform_mapping(mapping, child_operation, child_schema) new_mapping = self._transform_mapping(mapping, child_operation, child_schema)
alias_mapping = {alias: cst.alias for alias, cst in new_mapping.items()} alias_mapping = {alias: cst.alias for alias, cst in new_mapping.items()}
@ -159,13 +168,58 @@ class ChangeManager:
) )
self.cache.insert_inheritance(new_inheritance) self.cache.insert_inheritance(new_inheritance)
new_mapping = {alias_mapping[alias]: cst for alias, cst in new_mapping.items()} 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: if len(mapping) == 0:
return mapping return mapping
result: AliasMapping = {} result: CstMapping = {}
for alias, cst in mapping.items(): for alias, cst in mapping.items():
successor_id = self.cache.get_successor_for(cst.pk, operation.pk) successor_id = self.cache.get_successor_for(cst.pk, operation.pk)
if successor_id is None: if successor_id is None:
@ -192,3 +246,34 @@ class ChangeManager:
return INSERT_LAST return INSERT_LAST
prev_cst = destination.cache.by_id[inherited_prev_id] prev_cst = destination.cache.by_id[inherited_prev_id]
return cast(int, prev_cst.order) + 1 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

View File

@ -57,9 +57,9 @@ class OperationCreateSerializer(serializers.Serializer):
class OperationUpdateSerializer(serializers.Serializer): class OperationUpdateSerializer(serializers.Serializer):
''' Serializer: Operation creation. ''' ''' Serializer: Operation update. '''
class OperationUpdateData(serializers.ModelSerializer): class OperationUpdateData(serializers.ModelSerializer):
''' Serializer: Operation creation data. ''' ''' Serializer: Operation update data. '''
class Meta: class Meta:
''' serializer metadata. ''' ''' serializer metadata. '''
model = Operation model = Operation

View File

@ -58,14 +58,14 @@ class TestChangeConstituents(EndpointTester):
self.assertEqual(self.ks3.constituents().count(), 4) 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): def test_create_constituenta(self):
data = { data = {
'alias': 'X3', 'alias': 'X3',
'cst_type': CstType.BASE, 'cst_type': CstType.BASE,
'definition_formal': 'X4 = X5' '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']) new_cst = Constituenta.objects.get(pk=response.data['new_cst']['id'])
inherited_cst = Constituenta.objects.get(as_child__parent_id=new_cst.pk) inherited_cst = Constituenta.objects.get(as_child__parent_id=new_cst.pk)
self.assertEqual(self.ks1.constituents().count(), 3) self.assertEqual(self.ks1.constituents().count(), 3)
@ -74,13 +74,36 @@ class TestChangeConstituents(EndpointTester):
self.assertEqual(inherited_cst.order, 3) self.assertEqual(inherited_cst.order, 3)
self.assertEqual(inherited_cst.definition_formal, 'X1 = X2') 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): def test_rename_constituenta(self):
data = {'target': self.ks1X1.pk, 'alias': 'D21', 'cst_type': CstType.TERM} 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() self.ks1X1.refresh_from_db()
inherited_cst = Constituenta.objects.get(as_child__parent_id=self.ks1X1.pk) inherited_cst = Constituenta.objects.get(as_child__parent_id=self.ks1X1.pk)
self.assertEqual(self.ks1X1.alias, data['alias']) self.assertEqual(self.ks1X1.alias, data['alias'])
self.assertEqual(self.ks1X1.cst_type, data['cst_type']) self.assertEqual(self.ks1X1.cst_type, data['cst_type'])
self.assertEqual(inherited_cst.alias, 'D2') self.assertEqual(inherited_cst.alias, 'D2')
self.assertEqual(inherited_cst.cst_type, data['cst_type']) 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}')

View File

@ -254,7 +254,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
operation.alias = serializer.validated_data['item_data']['alias'] operation.alias = serializer.validated_data['item_data']['alias']
operation.title = serializer.validated_data['item_data']['title'] operation.title = serializer.validated_data['item_data']['title']
operation.comment = serializer.validated_data['item_data']['comment'] operation.comment = serializer.validated_data['item_data']['comment']
operation.save() operation.save(update_fields=['alias', 'title', 'comment'])
if operation.result is not None: if operation.result is not None:
can_edit = permissions.can_edit_item(request.user, operation.result) can_edit = permissions.can_edit_item(request.user, operation.result)

View File

@ -26,6 +26,16 @@ def extract_globals(expression: str) -> set[str]:
return set(re.findall(_RE_GLOBALS, expression)) 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): class CstType(TextChoices):
''' Type of constituenta. ''' ''' Type of constituenta. '''
BASE = 'basic' BASE = 'basic'
@ -114,15 +124,15 @@ class Constituenta(Model):
if change_aliases and self.alias in mapping: if change_aliases and self.alias in mapping:
modified = True modified = True
self.alias = mapping[self.alias] 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: if expression != self.definition_formal:
modified = True modified = True
self.definition_formal = expression 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: if term != self.term_raw:
modified = True modified = True
self.term_raw = term 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: if definition != self.definition_raw:
modified = True modified = True
self.definition_raw = definition self.definition_raw = definition

View File

@ -273,6 +273,54 @@ class RSForm:
self.save() self.save()
return result 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: def move_cst(self, target: list[Constituenta], destination: int) -> None:
''' Move list of constituents to specific position ''' ''' Move list of constituents to specific position '''
count_moved = 0 count_moved = 0

View File

@ -1,4 +1,4 @@
''' Django: Models. ''' ''' 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 from .RSForm import INSERT_LAST, RSForm

View File

@ -17,6 +17,7 @@ from .data_access import (
CstSerializer, CstSerializer,
CstSubstituteSerializer, CstSubstituteSerializer,
CstTargetSerializer, CstTargetSerializer,
CstUpdateSerializer,
InlineSynthesisSerializer, InlineSynthesisSerializer,
RSFormParseSerializer, RSFormParseSerializer,
RSFormSerializer, RSFormSerializer,

View File

@ -1,5 +1,5 @@
''' Serializers for persistent data manipulation. ''' ''' Serializers for persistent data manipulation. '''
from typing import Optional, cast from typing import cast
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
@ -38,25 +38,30 @@ class CstSerializer(serializers.ModelSerializer):
fields = '__all__' fields = '__all__'
read_only_fields = ('id', 'schema', 'order', 'alias', 'cst_type', 'definition_resolved', 'term_resolved') 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 class CstUpdateSerializer(serializers.Serializer):
definition: Optional[str] = data['definition_raw'] if 'definition_raw' in data else None ''' Serializer: Constituenta update. '''
term: Optional[str] = data['term_raw'] if 'term_raw' in data else None class ConstituentaUpdateData(serializers.ModelSerializer):
term_changed = 'term_forms' in data ''' Serializer: Operation creation data. '''
schema = RSForm(instance.schema) class Meta:
if definition is not None and definition != instance.definition_raw: ''' serializer metadata. '''
data['definition_resolved'] = schema.resolver().resolve(definition) model = Constituenta
if term is not None and term != instance.term_raw: fields = 'convention', 'definition_formal', 'definition_raw', 'term_raw', 'term_forms'
data['term_resolved'] = schema.resolver().resolve(term)
if data['term_resolved'] != instance.term_resolved and 'term_forms' not in data: target = PKField(
data['term_forms'] = [] many=False,
term_changed = data['term_resolved'] != instance.term_resolved queryset=Constituenta.objects.all().only('convention', 'definition_formal', 'definition_raw', 'term_raw')
result: Constituenta = super().update(instance, data) )
if term_changed: item_data = ConstituentaUpdateData()
schema.on_term_change([result.pk])
result.refresh_from_db() def validate(self, attrs):
schema.save() schema = cast(LibraryItem, self.context['schema'])
return result 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): class CstDetailsSerializer(serializers.ModelSerializer):

View File

@ -525,7 +525,7 @@ class TestConstituentaAPI(EndpointTester):
@decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch') @decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch')
def test_partial_update(self): 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.executeForbidden(data=data, schema=self.rsform_unowned.model.pk)
self.logout() self.logout()
@ -543,9 +543,11 @@ class TestConstituentaAPI(EndpointTester):
@decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch') @decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch')
def test_update_resolved_no_refs(self): def test_update_resolved_no_refs(self):
data = { data = {
'id': self.cst3.pk, 'target': self.cst3.pk,
'term_raw': 'New term', 'item_data': {
'definition_raw': 'New def' 'term_raw': 'New term',
'definition_raw': 'New def'
}
} }
response = self.executeOK(data=data, schema=self.rsform_owned.model.pk) response = self.executeOK(data=data, schema=self.rsform_owned.model.pk)
self.cst3.refresh_from_db() self.cst3.refresh_from_db()
@ -558,9 +560,11 @@ class TestConstituentaAPI(EndpointTester):
@decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch') @decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch')
def test_update_resolved_refs(self): def test_update_resolved_refs(self):
data = { data = {
'id': self.cst3.pk, 'target': self.cst3.pk,
'term_raw': '@{X1|nomn,sing}', 'item_data': {
'definition_raw': '@{X1|nomn,sing} @{X1|sing,datv}' 'term_raw': '@{X1|nomn,sing}',
'definition_raw': '@{X1|nomn,sing} @{X1|sing,datv}'
}
} }
response = self.executeOK(data=data, schema=self.rsform_owned.model.pk) response = self.executeOK(data=data, schema=self.rsform_owned.model.pk)
self.cst3.refresh_from_db() 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(self.cst3.definition_resolved, f'{self.cst1.term_resolved} form1')
self.assertEqual(response.data['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') @decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch')
def test_readonly_cst_fields(self): def test_readonly_cst_fields(self):
data = { data = {
'id': self.cst1.pk, 'target': self.cst1.pk,
'alias': 'X33', 'item_data': {
'order': 10 'alias': 'X33',
'order': 10
}
} }
response = self.executeOK(data=data, schema=self.rsform_owned.model.pk) response = self.executeOK(data=data, schema=self.rsform_owned.model.pk)
self.assertEqual(response.data['alias'], 'X1') self.assertEqual(response.data['alias'], 'X1')

View File

@ -41,14 +41,14 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
if self.action in [ if self.action in [
'load_trs', 'load_trs',
'create_cst', 'create_cst',
'delete_multiple_cst',
'rename_cst', 'rename_cst',
'update_cst',
'move_cst', 'move_cst',
'delete_multiple_cst',
'substitute', 'substitute',
'restore_order', 'restore_order',
'reset_aliases', 'reset_aliases',
'produce_structure', 'produce_structure',
'update_cst'
]: ]:
permission_list = [permissions.ItemEditor] permission_list = [permissions.ItemEditor]
elif self.action in [ elif self.action in [
@ -85,8 +85,8 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
else: else:
insert_after = data['insert_after'] insert_after = data['insert_after']
schema = m.RSForm(self._get_item())
with transaction.atomic(): with transaction.atomic():
schema = m.RSForm(self._get_item())
new_cst = schema.create_cst(data, insert_after) new_cst = schema.create_cst(data, insert_after)
hosts = LibraryItem.objects.filter(operations__result=schema.model) hosts = LibraryItem.objects.filter(operations__result=schema.model)
for host in hosts: for host in hosts:
@ -104,7 +104,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
@extend_schema( @extend_schema(
summary='update persistent attributes of a given constituenta', summary='update persistent attributes of a given constituenta',
tags=['RSForm'], tags=['RSForm'],
request=s.CstSerializer, request=s.CstUpdateSerializer,
responses={ responses={
c.HTTP_200_OK: s.CstSerializer, c.HTTP_200_OK: s.CstSerializer,
c.HTTP_400_BAD_REQUEST: None, 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') @action(detail=True, methods=['patch'], url_path='update-cst')
def update_cst(self, request: Request, pk) -> HttpResponse: def update_cst(self, request: Request, pk) -> HttpResponse:
''' Update persistent attributes of a given constituenta. ''' ''' Update persistent attributes of a given constituenta. '''
schema = self._get_item() model = self._get_item()
serializer = s.CstSerializer(data=request.data, partial=True) serializer = s.CstUpdateSerializer(data=request.data, partial=True, context={'schema': model})
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
cst = m.Constituenta.objects.get(pk=request.data['id']) cst = cast(m.Constituenta, serializer.validated_data['target'])
if cst.schema != schema: schema = m.RSForm(model)
raise ValidationError({ data = serializer.validated_data['item_data']
'schema': msg.constituentaNotInRSform(schema.title)
})
with transaction.atomic(): with transaction.atomic():
serializer.update(instance=cst, validated_data=serializer.validated_data) hosts = LibraryItem.objects.filter(operations__result=model)
# 'convention' | 'definition_formal' | 'definition_raw' | 'term_raw' | 'term_forms' old_data = schema.update_cst(cst, data)
for host in hosts:
ChangeManager(host).on_update_cst(cst, data, old_data, schema)
return Response( return Response(
status=c.HTTP_200_OK, status=c.HTTP_200_OK,
data=s.CstSerializer(cst).data data=s.CstSerializer(m.Constituenta.objects.get(pk=request.data['target'])).data
) )
@extend_schema( @extend_schema(

View File

@ -152,11 +152,12 @@ export interface ICstMovetoData extends IConstituentaList {
/** /**
* Represents data, used in updating persistent attributes in {@link IConstituenta}. * Represents data, used in updating persistent attributes in {@link IConstituenta}.
*/ */
export interface ICstUpdateData export interface ICstUpdateData {
extends Pick<IConstituentaMeta, 'id'>, target: ConstituentaID;
Partial< item_data: Partial<
Pick<IConstituentaMeta, 'convention' | 'definition_formal' | 'definition_raw' | 'term_raw' | 'term_forms'> Pick<IConstituentaMeta, 'convention' | 'definition_formal' | 'definition_raw' | 'term_raw' | 'term_forms'>
> {} >;
}
/** /**
* Represents data, used in renaming {@link IConstituenta}. * Represents data, used in renaming {@link IConstituenta}.

View File

@ -114,12 +114,21 @@ function FormConstituenta({
return; return;
} }
const data: ICstUpdateData = { const data: ICstUpdateData = {
id: state.id, target: state.id,
term_raw: term, item_data: {}
definition_formal: expression,
definition_raw: textDefinition,
convention: convention
}; };
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)); cstUpdate(data, () => toast.success(information.changesSaved));
} }
@ -216,7 +225,7 @@ function FormConstituenta({
onChange={event => setConvention(event.target.value)} onChange={event => setConvention(event.target.value)}
/> />
</AnimateFade> </AnimateFade>
{!showConvention && (!disabled || processing) ? ( <AnimateFade key='cst_convention_button' hideContent={showConvention || (disabled && !processing)}>
<button <button
key='cst_disable_comment' key='cst_disable_comment'
id='cst_disable_comment' id='cst_disable_comment'
@ -227,7 +236,7 @@ function FormConstituenta({
> >
Добавить комментарий Добавить комментарий
</button> </button>
) : null} </AnimateFade>
{!disabled || processing ? ( {!disabled || processing ? (
<div className='self-center flex'> <div className='self-center flex'>

View File

@ -323,8 +323,8 @@ export const RSEditState = ({
return; return;
} }
const data: ICstUpdateData = { const data: ICstUpdateData = {
id: activeCst.id, target: activeCst.id,
term_forms: forms item_data: { term_forms: forms }
}; };
model.cstUpdate(data, () => toast.success(information.changesSaved)); model.cstUpdate(data, () => toast.success(information.changesSaved));
}, },

View File

@ -57,7 +57,7 @@ function ViewConstituents({ expression, schema, activeCst, isBottom, onOpenEdit
className={clsx( className={clsx(
'border overflow-visible', // prettier: split-lines '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 'mt-3 mx-6 rounded-md md:w-[45.8rem]': isBottom
} }
)} )}