Compare commits

...

5 Commits

Author SHA1 Message Date
Ivan
fde844f5c2 F: Implement substitutions propagation
Some checks failed
Backend CI / build (3.12) (push) Has been cancelled
Frontend CI / build (22.x) (push) Has been cancelled
2024-08-12 16:51:24 +03:00
Ivan
58d0cb9afd R: Improve cst creation propagation 2024-08-11 21:22:53 +03:00
Ivan
efc4d1bd07 M: Improve cst table UI 2024-08-11 14:24:37 +03:00
Ivan
6a498ed2de M: Propagate cst_delete 2024-08-11 12:37:18 +03:00
Ivan
79ad54ed84 R: intorduce facade 2024-08-11 00:11:25 +03:00
21 changed files with 607 additions and 161 deletions

View File

@ -2,6 +2,7 @@
from typing import Optional, cast
from cctext import extract_entities
from rest_framework.serializers import ValidationError
from apps.library.models import LibraryItem
from apps.rsform.graph import Graph
@ -16,121 +17,44 @@ from apps.rsform.models import (
)
from .Inheritance import Inheritance
from .Operation import Operation
from .Operation import Operation, OperationType
from .OperationSchema import OperationSchema
from .Substitution import Substitution
CstMapping = dict[str, Constituenta]
# TODO: add more variety tests for cascade resolutions model
CstSubstitution = list[tuple[Constituenta, Constituenta]]
class ChangeManager:
''' Change propagation API. '''
class Cache:
''' Cache for RSForm constituents. '''
def __init__(self, oss: OperationSchema):
self._oss = oss
self._schemas: list[RSForm] = []
self._schema_by_id: dict[int, RSForm] = {}
self.operations = list(oss.operations().only('result_id'))
self.operation_by_id = {operation.pk: operation for operation in self.operations}
self.graph = Graph[int]()
for operation in self.operations:
self.graph.add_node(operation.pk)
for argument in self._oss.arguments().only('operation_id', 'argument_id'):
self.graph.add_edge(argument.argument_id, argument.operation_id)
self.is_loaded = False
self.substitutions: list[Substitution] = []
self.inheritance: dict[int, list[tuple[int, int]]] = {}
def insert(self, schema: RSForm) -> None:
''' Insert new schema. '''
if not self._schema_by_id.get(schema.model.pk):
self._insert_new(schema)
def get_schema(self, operation: Operation) -> Optional[RSForm]:
''' Get schema by Operation. '''
if operation.result_id is None:
return None
if operation.result_id in self._schema_by_id:
return self._schema_by_id[operation.result_id]
else:
schema = RSForm.from_id(operation.result_id)
schema.cache.ensure_loaded()
self._insert_new(schema)
return schema
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
raise ValueError(f'Operation for schema {schema.model.pk} not found')
def ensure_loaded(self) -> None:
''' Ensure propagation of changes. '''
if self.is_loaded:
return
self.is_loaded = True
self.substitutions = list(self._oss.substitutions().only('operation_id', 'original_id', 'substitution_id'))
for operation in self.operations:
self.inheritance[operation.pk] = []
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]:
''' Get child for parent inside target RSFrom. '''
if not ignore_substitution:
for sub in self.substitutions:
if sub.operation_id == operation and sub.original_id == parent_cst:
return sub.substitution_id
for item in self.inheritance[operation]:
if item[0] == parent_cst:
return item[1]
return None
def insert_inheritance(self, inheritance: Inheritance) -> None:
''' Insert new inheritance. '''
self.inheritance[inheritance.operation_id].append((inheritance.parent_id, inheritance.child_id))
def _insert_new(self, schema: RSForm) -> None:
self._schemas.append(schema)
self._schema_by_id[schema.model.pk] = schema
''' Change propagation wrapper for OSS. '''
def __init__(self, model: LibraryItem):
self.oss = OperationSchema(model)
self.cache = ChangeManager.Cache(self.oss)
self.cache = OssCache(self.oss)
def on_create_cst(self, new_cst: Constituenta, source: RSForm) -> None:
def after_create_cst(self, cst_list: list[Constituenta], source: RSForm) -> None:
''' Trigger cascade resolutions when new constituent is created. '''
self.cache.insert(source)
depend_aliases = new_cst.extract_references()
inserted_aliases = [cst.alias for cst in cst_list]
depend_aliases: set[str] = set()
for new_cst in cst_list:
depend_aliases.update(new_cst.extract_references())
depend_aliases.difference_update(inserted_aliases)
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)
self._cascade_create_cst(new_cst, operation, alias_mapping)
self._cascade_create_cst(cst_list, operation, alias_mapping)
def on_change_cst_type(self, target: Constituenta, source: RSForm) -> None:
def after_change_cst_type(self, target: Constituenta, source: RSForm) -> None:
''' Trigger cascade resolutions when constituenta type is changed. '''
self.cache.insert(source)
operation = self.cache.get_operation(source)
self._cascade_change_cst_type(target.pk, target.cst_type, operation)
def on_update_cst(self, target: Constituenta, data: dict, old_data: dict, source: RSForm) -> None:
def after_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)
@ -142,7 +66,40 @@ class ChangeManager:
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:
def before_delete(self, target: list[Constituenta], source: RSForm) -> None:
''' Trigger cascade resolutions before constituents are deleted. '''
self.cache.insert(source)
operation = self.cache.get_operation(source)
self._cascade_before_delete(target, operation)
def before_substitute(self, substitutions: CstSubstitution, source: RSForm) -> None:
''' Trigger cascade resolutions before constituents are substituted. '''
self.cache.insert(source)
operation = self.cache.get_operation(source)
self._cascade_before_substitute(substitutions, operation)
def _cascade_before_substitute(
self,
substitutions: CstSubstitution,
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]
child_schema = self.cache.get_schema(child_operation)
if child_schema is None:
continue
child_schema.cache.ensure_loaded()
new_substitutions = self._transform_substitutions(substitutions, child_operation, child_schema)
if len(new_substitutions) == 0:
continue
self._cascade_before_substitute(new_substitutions, child_operation)
child_schema.substitute(new_substitutions)
def _cascade_create_cst(self, cst_list: list[Constituenta], operation: Operation, mapping: CstMapping) -> None:
children = self.cache.graph.outputs[operation.pk]
if len(children) == 0:
return
@ -159,16 +116,17 @@ class ChangeManager:
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()}
insert_where = self._determine_insert_position(prototype, child_operation, source_schema, child_schema)
new_cst = child_schema.insert_copy([prototype], insert_where, alias_mapping)[0]
new_inheritance = Inheritance.objects.create(
operation=child_operation,
child=new_cst,
parent=prototype
)
self.cache.insert_inheritance(new_inheritance)
insert_where = self._determine_insert_position(cst_list[0], child_operation, source_schema, child_schema)
new_cst_list = child_schema.insert_copy(cst_list, insert_where, alias_mapping)
for index, cst in enumerate(new_cst_list):
new_inheritance = Inheritance.objects.create(
operation=child_operation,
child=cst,
parent=cst_list[index]
)
self.cache.insert_inheritance(new_inheritance)
new_mapping = {alias_mapping[alias]: cst for alias, cst in new_mapping.items()}
self._cascade_create_cst(new_cst, child_operation, new_mapping)
self._cascade_create_cst(new_cst_list, 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]
@ -177,7 +135,7 @@ class ChangeManager:
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)
successor_id = self.cache.get_inheritor(cst_id, child_id)
if successor_id is None:
continue
child_schema = self.cache.get_schema(child_operation)
@ -197,7 +155,7 @@ class ChangeManager:
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)
successor_id = self.cache.get_inheritor(cst_id, child_id)
if successor_id is None:
continue
child_schema = self.cache.get_schema(child_operation)
@ -216,12 +174,37 @@ class ChangeManager:
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 _cascade_before_delete(self, target: list[Constituenta], 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]
child_schema = self.cache.get_schema(child_operation)
if child_schema is None:
continue
child_schema.cache.ensure_loaded()
# TODO: check if substitutions are affected. Undo substitutions before deletion
child_target_cst = []
child_target_ids = []
for cst in target:
successor_id = self.cache.get_inheritor(cst.pk, child_id)
if successor_id is not None:
child_target_ids.append(successor_id)
child_target_cst.append(child_schema.cache.by_id[successor_id])
self._cascade_before_delete(child_target_cst, child_operation)
self.cache.remove_cst(child_target_ids, child_id)
child_schema.delete_cst(child_target_cst)
def _transform_mapping(self, mapping: CstMapping, operation: Operation, schema: RSForm) -> CstMapping:
if len(mapping) == 0:
return mapping
result: CstMapping = {}
for alias, cst in mapping.items():
successor_id = self.cache.get_successor_for(cst.pk, operation.pk)
successor_id = self.cache.get_successor(cst.pk, operation.pk)
if successor_id is None:
continue
successor = schema.cache.by_id.get(successor_id)
@ -240,8 +223,7 @@ class ChangeManager:
if prototype.order == 1:
return 1
prev_cst = source.cache.constituents[prototype.order - 2]
inherited_prev_id = self.cache.get_successor_for(
source.cache.constituents[prototype.order - 2].pk, operation.pk)
inherited_prev_id = self.cache.get_successor(prev_cst.pk, operation.pk)
if inherited_prev_id is None:
return INSERT_LAST
prev_cst = destination.cache.by_id[inherited_prev_id]
@ -277,3 +259,142 @@ class ChangeManager:
if replace_entities(old_data['definition_raw'], mapping) == cst.definition_raw:
new_data['definition_raw'] = replace_entities(data['definition_raw'], mapping)
return new_data
def _transform_substitutions(
self,
target: CstSubstitution,
operation: Operation,
schema: RSForm
) -> CstSubstitution:
result: CstSubstitution = []
for current_sub in target:
sub_replaced = False
new_substitution_id = self.cache.get_inheritor(current_sub[1].pk, operation.pk)
if new_substitution_id is None:
for sub in self.cache.substitutions[operation.pk]:
if sub.original_id == current_sub[1].pk:
sub_replaced = True
new_substitution_id = self.cache.get_inheritor(sub.original_id, operation.pk)
break
new_original_id = self.cache.get_inheritor(current_sub[0].pk, operation.pk)
original_replaced = False
if new_original_id is None:
for sub in self.cache.substitutions[operation.pk]:
if sub.original_id == current_sub[0].pk:
original_replaced = True
sub.original_id = current_sub[1].pk
sub.save()
new_original_id = new_substitution_id
new_substitution_id = self.cache.get_inheritor(sub.substitution_id, operation.pk)
break
if sub_replaced and original_replaced:
raise ValidationError({'propagation': 'Substitution breaks OSS substitutions.'})
for sub in self.cache.substitutions[operation.pk]:
if sub.substitution_id == current_sub[0].pk:
sub.substitution_id = current_sub[1].pk
sub.save()
if new_original_id is not None and new_substitution_id is not None:
result.append((schema.cache.by_id[new_original_id], schema.cache.by_id[new_substitution_id]))
return result
class OssCache:
''' Cache for OSS data. '''
def __init__(self, oss: OperationSchema):
self._oss = oss
self._schemas: list[RSForm] = []
self._schema_by_id: dict[int, RSForm] = {}
self.operations = list(oss.operations().only('result_id'))
self.operation_by_id = {operation.pk: operation for operation in self.operations}
self.graph = Graph[int]()
for operation in self.operations:
self.graph.add_node(operation.pk)
for argument in self._oss.arguments().only('operation_id', 'argument_id'):
self.graph.add_edge(argument.argument_id, argument.operation_id)
self.is_loaded = False
self.substitutions: dict[int, list[Substitution]] = {}
self.inheritance: dict[int, list[Inheritance]] = {}
def insert(self, schema: RSForm) -> None:
''' Insert new schema. '''
if not self._schema_by_id.get(schema.model.pk):
self._insert_new(schema)
def get_schema(self, operation: Operation) -> Optional[RSForm]:
''' Get schema by Operation. '''
if operation.result_id is None:
return None
if operation.result_id in self._schema_by_id:
return self._schema_by_id[operation.result_id]
else:
schema = RSForm.from_id(operation.result_id)
schema.cache.ensure_loaded()
self._insert_new(schema)
return schema
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
raise ValueError(f'Operation for schema {schema.model.pk} not found')
def ensure_loaded(self) -> None:
''' Ensure propagation of changes. '''
if self.is_loaded:
return
self.is_loaded = True
for operation in self.operations:
if operation.operation_type != OperationType.INPUT:
self.inheritance[operation.pk] = []
self.substitutions[operation.pk] = []
for sub in self._oss.substitutions().only('operation_id', 'original_id', 'substitution_id'):
self.substitutions[sub.operation_id].append(sub)
for item in self._oss.inheritance().only('operation_id', 'parent_id', 'child_id'):
self.inheritance[item.operation_id].append(item)
def get_inheritor(self, parent_cst: int, operation: int) -> Optional[int]:
''' Get child for parent inside target RSFrom. '''
for item in self.inheritance[operation]:
if item.parent_id == parent_cst:
return item.child_id
return None
def get_successor(self, parent_cst: int, operation: int) -> Optional[int]:
''' Get child for parent inside target RSFrom including substitutions. '''
for sub in self.substitutions[operation]:
if sub.original_id == parent_cst:
return self.get_inheritor(sub.substitution_id, operation)
return self.get_inheritor(parent_cst, operation)
def insert_inheritance(self, inheritance: Inheritance) -> None:
''' Insert new inheritance. '''
self.inheritance[inheritance.operation_id].append(inheritance)
def insert_substitution(self, sub: Substitution) -> None:
''' Insert new inheritance. '''
self.substitutions[sub.operation_id].append(sub)
def remove_cst(self, target: list[int], operation: int) -> None:
''' Remove constituents from operation. '''
subs_to_delete = [
sub for sub in self.substitutions[operation]
if sub.original_id in target or sub.substitution_id in target
]
for sub in subs_to_delete:
self.substitutions[operation].remove(sub)
inherit_to_delete = [item for item in self.inheritance[operation] if item.child_id in target]
for item in inherit_to_delete:
self.inheritance[operation].remove(item)
def _insert_new(self, schema: RSForm) -> None:
self._schemas.append(schema)
self._schema_by_id[schema.model.pk] = schema

View File

@ -0,0 +1,49 @@
''' Models: Change propagation facade - managing all changes in OSS. '''
from apps.library.models import LibraryItem
from apps.rsform.models import Constituenta, RSForm
from .ChangeManager import ChangeManager
def _get_oss_hosts(item: LibraryItem) -> list[LibraryItem]:
''' Get all hosts for LibraryItem. '''
return list(LibraryItem.objects.filter(operations__result=item).only('pk'))
class PropagationFacade:
''' Change propagation API. '''
@classmethod
def after_create_cst(cls, new_cst: list[Constituenta], source: RSForm) -> None:
''' Trigger cascade resolutions when new constituent is created. '''
hosts = _get_oss_hosts(source.model)
for host in hosts:
ChangeManager(host).after_create_cst(new_cst, source)
@classmethod
def after_change_cst_type(cls, target: Constituenta, source: RSForm) -> None:
''' Trigger cascade resolutions when constituenta type is changed. '''
hosts = _get_oss_hosts(source.model)
for host in hosts:
ChangeManager(host).after_change_cst_type(target, source)
@classmethod
def after_update_cst(cls, target: Constituenta, data: dict, old_data: dict, source: RSForm) -> None:
''' Trigger cascade resolutions when constituenta data is changed. '''
hosts = _get_oss_hosts(source.model)
for host in hosts:
ChangeManager(host).after_update_cst(target, data, old_data, source)
@classmethod
def before_delete(cls, target: list[Constituenta], source: RSForm) -> None:
''' Trigger cascade resolutions before constituents are deleted. '''
hosts = _get_oss_hosts(source.model)
for host in hosts:
ChangeManager(host).before_delete(target, source)
@classmethod
def before_substitute(cls, substitutions: list[tuple[Constituenta, Constituenta]], source: RSForm) -> None:
''' Trigger cascade resolutions before constituents are substituted. '''
hosts = _get_oss_hosts(source.model)
for host in hosts:
ChangeManager(host).before_substitute(substitutions, source)

View File

@ -29,4 +29,4 @@ class Substitution(Model):
verbose_name_plural = 'Таблицы отождествлений'
def __str__(self) -> str:
return f'{self.original} -> {self.substitution}'
return f'{self.substitution} -> {self.original}'

View File

@ -6,3 +6,4 @@ from .Inheritance import Inheritance
from .Operation import Operation, OperationType
from .OperationSchema import OperationSchema
from .Substitution import Substitution
from .PropagationFacade import PropagationFacade

View File

@ -1,3 +1,4 @@
''' Tests. '''
from .s_models import *
from .s_propagation import *
from .s_views import *

View File

@ -52,7 +52,7 @@ class TestSynthesisSubstitution(TestCase):
def test_str(self):
testStr = f'{self.ks1X1} -> {self.ks2X1}'
testStr = f'{self.ks2X1} -> {self.ks1X1}'
self.assertEqual(str(self.substitution), testStr)

View File

@ -0,0 +1,4 @@
''' Tests for REST API OSS propagation. '''
from .t_attributes import *
from .t_constituents import *
from .t_substitutions import *

View File

@ -57,7 +57,6 @@ class TestChangeConstituents(EndpointTester):
self.ks3 = RSForm(self.operation3.result)
self.assertEqual(self.ks3.constituents().count(), 4)
@decl_endpoint('/api/rsforms/{schema}/create-cst', method='post')
def test_create_constituenta(self):
data = {
@ -107,3 +106,29 @@ class TestChangeConstituents(EndpointTester):
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}')
@decl_endpoint('/api/rsforms/{schema}/delete-multiple-cst', method='patch')
def test_delete_constituenta(self):
data = {'items': [self.ks2X1.pk]}
response = self.executeOK(data=data, schema=self.ks2.model.pk)
inherited_cst = Constituenta.objects.get(as_child__parent_id=self.ks2D1.pk)
self.ks2D1.refresh_from_db()
self.assertEqual(self.ks2.constituents().count(), 1)
self.assertEqual(self.ks3.constituents().count(), 3)
self.assertEqual(self.ks2D1.definition_formal, r'DEL\DEL')
self.assertEqual(inherited_cst.definition_formal, r'DEL\DEL')
@decl_endpoint('/api/rsforms/{schema}/substitute', method='patch')
def test_substitute(self):
d2 = self.ks3.insert_new('D2', cst_type=CstType.TERM, definition_formal=r'X1\X2\X3')
data = {'substitutions': [{
'original': self.ks1X1.pk,
'substitution': self.ks1X2.pk
}]}
self.executeOK(data=data, schema=self.ks1.model.pk)
self.ks1X2.refresh_from_db()
d2.refresh_from_db()
self.assertEqual(self.ks1.constituents().count(), 1)
self.assertEqual(self.ks3.constituents().count(), 4)
self.assertEqual(self.ks1X2.order, 1)
self.assertEqual(d2.definition_formal, r'X2\X2\X3')

View File

@ -0,0 +1,160 @@
''' Testing API: Change substitutions in OSS. '''
from apps.oss.models import OperationSchema, OperationType
from apps.rsform.models import Constituenta, CstType, RSForm
from shared.EndpointTester import EndpointTester, decl_endpoint
class TestChangeSubstitutions(EndpointTester):
''' Testing Substitutions change propagation in OSS. '''
def setUp(self):
super().setUp()
self.owned = OperationSchema.create(
title='Test',
alias='T1',
owner=self.user
)
self.owned_id = self.owned.model.pk
self.ks1 = RSForm.create(
alias='KS1',
title='Test1',
owner=self.user
)
self.ks1X1 = self.ks1.insert_new('X1', convention='KS1X1')
self.ks1X2 = self.ks1.insert_new('X2', convention='KS1X2')
self.ks1D1 = self.ks1.insert_new('D1', definition_formal='X1 X2', convention='KS1D1')
self.ks2 = RSForm.create(
alias='KS2',
title='Test2',
owner=self.user
)
self.ks2X1 = self.ks2.insert_new('X1', convention='KS2X1')
self.ks2X2 = self.ks2.insert_new('X2', convention='KS2X2')
self.ks2S1 = self.ks2.insert_new(
alias='S1',
definition_formal=r'X1',
convention='KS2S1'
)
self.ks3 = RSForm.create(
alias='KS3',
title='Test3',
owner=self.user
)
self.ks3X1 = self.ks3.insert_new('X1', convention='KS3X1')
self.ks3D1 = self.ks3.insert_new(
alias='D1',
definition_formal='X1 X1',
convention='KS3D1'
)
self.operation1 = self.owned.create_operation(
alias='1',
operation_type=OperationType.INPUT,
result=self.ks1.model
)
self.operation2 = self.owned.create_operation(
alias='2',
operation_type=OperationType.INPUT,
result=self.ks2.model
)
self.operation3 = self.owned.create_operation(
alias='3',
operation_type=OperationType.INPUT,
result=self.ks3.model
)
self.operation4 = self.owned.create_operation(
alias='4',
operation_type=OperationType.SYNTHESIS
)
self.owned.set_arguments(self.operation4, [self.operation1, self.operation2])
self.owned.set_substitutions(self.operation4, [{
'original': self.ks1X1,
'substitution': self.ks2S1
}])
self.owned.execute_operation(self.operation4)
self.operation4.refresh_from_db()
self.ks4 = RSForm(self.operation4.result)
self.ks4X1 = Constituenta.objects.get(as_child__parent_id=self.ks1X2.pk)
self.ks4S1 = Constituenta.objects.get(as_child__parent_id=self.ks2S1.pk)
self.ks4D1 = Constituenta.objects.get(as_child__parent_id=self.ks1D1.pk)
self.ks4D2 = self.ks4.insert_new(
alias='D2',
definition_formal=r'X1 X2 X3 S1 D1',
convention='KS4D2'
)
self.operation5 = self.owned.create_operation(
alias='5',
operation_type=OperationType.SYNTHESIS
)
self.owned.set_arguments(self.operation5, [self.operation4, self.operation3])
self.owned.set_substitutions(self.operation5, [{
'original': self.ks4X1,
'substitution': self.ks3X1
}])
self.owned.execute_operation(self.operation5)
self.operation5.refresh_from_db()
self.ks5 = RSForm(self.operation5.result)
self.ks5D4 = self.ks5.insert_new(
alias='D4',
definition_formal=r'X1 X2 X3 S1 D1 D2 D3',
convention='KS5D4'
)
def test_oss_setup(self):
self.assertEqual(self.ks1.constituents().count(), 3)
self.assertEqual(self.ks2.constituents().count(), 3)
self.assertEqual(self.ks3.constituents().count(), 2)
self.assertEqual(self.ks4.constituents().count(), 6)
self.assertEqual(self.ks5.constituents().count(), 8)
self.assertEqual(self.ks4D1.definition_formal, 'S1 X1')
@decl_endpoint('/api/rsforms/{schema}/substitute', method='patch')
def test_substitute_original(self):
data = {'substitutions': [{
'original': self.ks1X1.pk,
'substitution': self.ks1X2.pk
}]}
self.executeOK(data=data, schema=self.ks1.model.pk)
self.ks4D1.refresh_from_db()
self.ks4D2.refresh_from_db()
self.ks5D4.refresh_from_db()
subs1_2 = self.operation4.getSubstitutions()
self.assertEqual(subs1_2.count(), 1)
self.assertEqual(subs1_2.first().original, self.ks1X2)
self.assertEqual(subs1_2.first().substitution, self.ks2S1)
subs3_4 = self.operation5.getSubstitutions()
self.assertEqual(subs3_4.count(), 1)
self.assertEqual(subs3_4.first().original, self.ks4S1)
self.assertEqual(subs3_4.first().substitution, self.ks3X1)
self.assertEqual(self.ks4D1.definition_formal, r'S1 S1')
self.assertEqual(self.ks4D2.definition_formal, r'S1 X2 X3 S1 D1')
self.assertEqual(self.ks5D4.definition_formal, r'X1 X2 X3 X1 D1 D2 D3')
@decl_endpoint('/api/rsforms/{schema}/substitute', method='patch')
def test_substitute_substitution(self):
data = {'substitutions': [{
'original': self.ks2S1.pk,
'substitution': self.ks2X1.pk
}]}
self.executeOK(data=data, schema=self.ks2.model.pk)
self.ks4D1.refresh_from_db()
self.ks4D2.refresh_from_db()
self.ks5D4.refresh_from_db()
subs1_2 = self.operation4.getSubstitutions()
self.assertEqual(subs1_2.count(), 1)
self.assertEqual(subs1_2.first().original, self.ks1X1)
self.assertEqual(subs1_2.first().substitution, self.ks2X1)
subs3_4 = self.operation5.getSubstitutions()
self.assertEqual(subs3_4.count(), 1)
self.assertEqual(subs3_4.first().original, self.ks4X1)
self.assertEqual(subs3_4.first().substitution, self.ks3X1)
self.assertEqual(self.ks4D1.definition_formal, r'X2 X1')
self.assertEqual(self.ks4D2.definition_formal, r'X1 X2 X3 X2 D1')
self.assertEqual(self.ks5D4.definition_formal, r'X1 X2 X3 X2 D1 D2 D3')

View File

@ -1,4 +1,2 @@
''' Tests for REST API. '''
from .t_change_attributes import *
from .t_change_constituents import *
from .t_oss import *

View File

@ -23,6 +23,7 @@ from .api_RSLanguage import (
from .Constituenta import Constituenta, CstType, extract_globals
INSERT_LAST: int = -1
DELETED_ALIAS = 'DEL'
class RSForm:
@ -235,7 +236,7 @@ class RSForm:
**kwargs
)
self.cache.insert(result)
self.save()
self.save(update_fields=['time_update'])
return result
def insert_copy(self, items: list[Constituenta], position: int = INSERT_LAST,
@ -270,7 +271,7 @@ class RSForm:
new_cst = Constituenta.objects.bulk_create(result)
self.cache.insert_multi(new_cst)
self.save()
self.save(update_fields=['time_update'])
return result
# pylint: disable=too-many-branches
@ -318,7 +319,7 @@ class RSForm:
cst.save()
if term_changed:
self.on_term_change([cst.pk])
self.save()
self.save(update_fields=['time_update'])
return old_data
def move_cst(self, target: list[Constituenta], destination: int) -> None:
@ -344,15 +345,17 @@ class RSForm:
cst.order = destination + size + count_bot
count_bot += 1
Constituenta.objects.bulk_update(cst_list, ['order'])
self.save()
self.save(update_fields=['time_update'])
def delete_cst(self, target: Iterable[Constituenta]) -> None:
''' Delete multiple constituents. Do not check if listCst are from this schema. '''
mapping = {cst.alias: DELETED_ALIAS for cst in target}
self.cache.ensure_loaded()
self.cache.remove_multi(target)
self.apply_mapping(mapping)
Constituenta.objects.filter(pk__in=[cst.pk for cst in target]).delete()
self._reset_order()
self.resolve_all_text()
self.save()
self.save(update_fields=['time_update'])
def substitute(self, substitutions: list[tuple[Constituenta, Constituenta]]) -> None:
''' Execute constituenta substitution. '''
@ -360,12 +363,12 @@ class RSForm:
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._reset_order()
self.apply_mapping(mapping)
self.on_term_change([substitution.pk for substitution in replacements])
@ -414,7 +417,7 @@ class RSForm:
if cst.apply_mapping(mapping, change_aliases):
update_list.append(cst)
Constituenta.objects.bulk_update(update_list, ['alias', 'definition_formal', 'term_raw', 'definition_raw'])
self.save()
self.save(update_fields=['time_update'])
def resolve_all_text(self) -> None:
''' Trigger reference resolution for all texts. '''
@ -445,7 +448,7 @@ class RSForm:
data=data
)
def produce_structure(self, target: Constituenta, parse: dict) -> list[int]:
def produce_structure(self, target: Constituenta, parse: dict) -> list[Constituenta]:
''' Add constituents for each structural element of the target. '''
expressions = generate_structure(
alias=target.alias,
@ -471,12 +474,12 @@ class RSForm:
definition_formal=text,
cst_type=cst_type
)
result.append(new_item.pk)
result.append(new_item)
free_index = free_index + 1
position = position + 1
self.cache.clear()
self.save()
self.cache.insert_multi(result)
self.save(update_fields=['time_update'])
return result
def _shift_positions(self, start: int, shift: int) -> None:

View File

@ -1,4 +1,4 @@
''' Django: Models. '''
from .Constituenta import Constituenta, CstType, extract_globals, replace_entities, replace_globals
from .RSForm import INSERT_LAST, RSForm
from .RSForm import DELETED_ALIAS, INSERT_LAST, RSForm, SemanticInfo

View File

@ -174,6 +174,26 @@ class TestRSForm(DBTester):
self.assertEqual(s2.definition_raw, '@{X11|plur}')
def test_delete_cst(self):
x1 = self.schema.insert_new('X1')
x2 = self.schema.insert_new('X2')
d1 = self.schema.insert_new(
alias='D1',
definition_formal='X1 = X2',
definition_raw='@{X1|sing}',
term_raw='@{X2|plur}'
)
self.schema.delete_cst([x1])
x2.refresh_from_db()
d1.refresh_from_db()
self.assertEqual(self.schema.constituents().count(), 2)
self.assertEqual(x2.order, 1)
self.assertEqual(d1.order, 2)
self.assertEqual(d1.definition_formal, 'DEL = X2')
self.assertEqual(d1.definition_raw, '@{DEL|sing}')
self.assertEqual(d1.term_raw, '@{X2|plur}')
def test_apply_mapping(self):
x1 = self.schema.insert_new('X1')
x2 = self.schema.insert_new('X11')

View File

@ -16,7 +16,7 @@ from rest_framework.serializers import ValidationError
from apps.library.models import AccessPolicy, LibraryItem, LibraryItemType, LocationHead
from apps.library.serializers import LibraryItemSerializer
from apps.oss.models import ChangeManager
from apps.oss.models import PropagationFacade
from apps.users.models import User
from shared import messages as msg
from shared import permissions, utility
@ -84,15 +84,10 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
insert_after = None
else:
insert_after = data['insert_after']
schema = m.RSForm(self._get_item())
with transaction.atomic():
new_cst = schema.create_cst(data, insert_after)
hosts = LibraryItem.objects.filter(operations__result=schema.model)
for host in hosts:
ChangeManager(host).on_create_cst(new_cst, schema)
PropagationFacade.after_create_cst([new_cst], schema)
return Response(
status=c.HTTP_201_CREATED,
data={
@ -118,16 +113,12 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
model = self._get_item()
serializer = s.CstUpdateSerializer(data=request.data, partial=True, context={'schema': model})
serializer.is_valid(raise_exception=True)
cst = cast(m.Constituenta, serializer.validated_data['target'])
schema = m.RSForm(model)
data = serializer.validated_data['item_data']
with transaction.atomic():
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)
PropagationFacade.after_update_cst(cst, data, old_data, schema)
return Response(
status=c.HTTP_200_OK,
data=s.CstSerializer(m.Constituenta.objects.get(pk=request.data['target'])).data
@ -164,13 +155,16 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
status=c.HTTP_400_BAD_REQUEST,
data={f'{cst.pk}': msg.constituentaNoStructure()}
)
schema = m.RSForm(model)
with transaction.atomic():
result = m.RSForm(model).produce_structure(cst, cst_parse)
new_cst = schema.produce_structure(cst, cst_parse)
PropagationFacade.after_create_cst(new_cst, schema)
return Response(
status=c.HTTP_200_OK,
data={
'cst_list': result,
'schema': s.RSFormParseSerializer(model).data
'cst_list': [cst.pk for cst in new_cst],
'schema': s.RSFormParseSerializer(schema.model).data
}
)
@ -191,28 +185,24 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
model = self._get_item()
serializer = s.CstRenameSerializer(data=request.data, context={'schema': model})
serializer.is_valid(raise_exception=True)
cst = cast(m.Constituenta, serializer.validated_data['target'])
changed_type = cst.cst_type != serializer.validated_data['cst_type']
mapping = {cst.alias: serializer.validated_data['alias']}
cst.alias = serializer.validated_data['alias']
cst.cst_type = serializer.validated_data['cst_type']
schema = m.RSForm(model)
with transaction.atomic():
cst.alias = serializer.validated_data['alias']
cst.cst_type = serializer.validated_data['cst_type']
cst.save()
schema.apply_mapping(mapping=mapping, change_aliases=False)
schema.save()
cst.refresh_from_db()
if changed_type:
hosts = LibraryItem.objects.filter(operations__result=model)
for host in hosts:
ChangeManager(host).on_change_cst_type(cst, schema)
PropagationFacade.after_change_cst_type(cst, schema)
return Response(
status=c.HTTP_200_OK,
data={
'new_cst': s.CstSerializer(cst).data,
'schema': s.RSFormParseSerializer(model).data
'schema': s.RSFormParseSerializer(schema.model).data
}
)
@ -236,19 +226,18 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
context={'schema': model}
)
serializer.is_valid(raise_exception=True)
schema = m.RSForm(model)
substitutions: list[tuple[m.Constituenta, m.Constituenta]] = []
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'])
substitutions.append((original, replacement))
m.RSForm(model).substitute(substitutions)
model.refresh_from_db()
PropagationFacade.before_substitute(substitutions, schema)
schema.substitute(substitutions)
return Response(
status=c.HTTP_200_OK,
data=s.RSFormParseSerializer(model).data
data=s.RSFormParseSerializer(schema.model).data
)
@extend_schema(
@ -271,11 +260,14 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
context={'schema': model}
)
serializer.is_valid(raise_exception=True)
cst_list: list[m.Constituenta] = serializer.validated_data['items']
schema = m.RSForm(model)
with transaction.atomic():
m.RSForm(model).delete_cst(serializer.validated_data['items'])
PropagationFacade.before_delete(cst_list, schema)
schema.delete_cst(cst_list)
return Response(
status=c.HTTP_200_OK,
data=s.RSFormParseSerializer(model).data
data=s.RSFormParseSerializer(schema.model).data
)
@extend_schema(
@ -322,7 +314,8 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
def reset_aliases(self, request: Request, pk) -> HttpResponse:
''' Endpoint: Recreate all aliases based on order. '''
model = self._get_item()
m.RSForm(model).reset_aliases()
schema = m.RSForm(model)
schema.reset_aliases()
return Response(
status=c.HTTP_200_OK,
data=s.RSFormParseSerializer(model).data
@ -588,6 +581,8 @@ def inline_synthesis(request: Request) -> HttpResponse:
with transaction.atomic():
new_items = receiver.insert_copy(items)
PropagationFacade.after_create_cst(new_items, receiver)
substitutions: list[tuple[m.Constituenta, m.Constituenta]] = []
for substitution in serializer.validated_data['substitutions']:
original = cast(m.Constituenta, substitution['original'])
@ -600,6 +595,9 @@ def inline_synthesis(request: Request) -> HttpResponse:
replacement = new_items[index]
substitutions.append((original, replacement))
receiver.substitute(substitutions)
# TODO: propagate substitutions
receiver.restore_order()
return Response(

View File

@ -24,6 +24,7 @@ function TextArea({
return (
<div
className={clsx(
'w-full',
{
'flex flex-col gap-2': !dense,
'flex flex-grow items-center gap-3': dense

View File

@ -0,0 +1,28 @@
import clsx from 'clsx';
import { globals } from '@/utils/constants';
import { truncateText } from '@/utils/utils';
import { CProps } from '../props';
export interface TextContentProps extends CProps.Styling {
text: string;
maxLength?: number;
}
function TextContent({ className, text, maxLength, ...restProps }: TextContentProps) {
const truncated = maxLength ? truncateText(text, maxLength) : text;
const isTruncated = maxLength && text.length > maxLength;
return (
<div
className={clsx('text-xs text-pretty', className)}
data-tooltip-id={isTruncated ? globals.tooltip : undefined}
data-tooltip-content={isTruncated ? text : undefined}
{...restProps}
>
{truncated}
</div>
);
}
export default TextContent;

View File

@ -152,7 +152,7 @@ export const OptionsState = ({ children }: OptionsStateProps) => {
id={`${globals.tooltip}`}
layer='z-topmost'
place='right-start'
className={clsx('mt-3 translate-y-1/2', 'max-w-[20rem]')}
className={clsx('mt-1 translate-y-1/2', 'max-w-[20rem]')}
/>
{children}
</>

View File

@ -345,6 +345,8 @@ export const RSFormState = ({ itemID, versionID, children }: RSFormStateProps) =
setSchema(newData);
library.localUpdateTimestamp(newData.id);
if (callback) callback();
// TODO: deal with OSS cache invalidation
}
});
},
@ -414,6 +416,8 @@ export const RSFormState = ({ itemID, versionID, children }: RSFormStateProps) =
setSchema(newData.schema);
library.localUpdateTimestamp(newData.schema.id);
if (callback) callback(newData.new_cst);
// TODO: deal with OSS cache invalidation
}
});
},
@ -432,6 +436,8 @@ export const RSFormState = ({ itemID, versionID, children }: RSFormStateProps) =
setSchema(newData);
library.localUpdateTimestamp(newData.id);
if (callback) callback();
// TODO: deal with OSS cache invalidation
}
});
},
@ -450,6 +456,8 @@ export const RSFormState = ({ itemID, versionID, children }: RSFormStateProps) =
reload(setProcessing, () => {
library.localUpdateTimestamp(Number(itemID));
if (callback) callback(newData);
// TODO: deal with OSS cache invalidation
})
});
},
@ -467,7 +475,11 @@ export const RSFormState = ({ itemID, versionID, children }: RSFormStateProps) =
onSuccess: newData => {
setSchema(newData.schema);
library.localUpdateTimestamp(newData.schema.id);
if (callback) callback(newData.new_cst);
if (library.globalOSS?.schemas.includes(newData.schema.id)) {
library.reloadOSS(() => {
if (callback) callback(newData.new_cst);
});
} else if (callback) callback(newData.new_cst);
}
});
},
@ -485,7 +497,11 @@ export const RSFormState = ({ itemID, versionID, children }: RSFormStateProps) =
onSuccess: newData => {
setSchema(newData);
library.localUpdateTimestamp(newData.id);
if (callback) callback();
if (library.globalOSS?.schemas.includes(newData.id)) {
library.reloadOSS(() => {
if (callback) callback();
});
} else if (callback) callback();
}
});
},
@ -611,6 +627,8 @@ export const RSFormState = ({ itemID, versionID, children }: RSFormStateProps) =
setSchema(newData);
library.localUpdateTimestamp(Number(itemID));
if (callback) callback(newData);
// TODO: deal with OSS cache invalidation
}
});
},

View File

@ -7,6 +7,7 @@ import BadgeConstituenta from '@/components/info/BadgeConstituenta';
import { CProps } from '@/components/props';
import DataTable, { createColumnHelper, RowSelectionState, VisibilityState } from '@/components/ui/DataTable';
import NoData from '@/components/ui/NoData';
import TextContent from '@/components/ui/TextContent';
import TextURL from '@/components/ui/TextURL';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import useWindowSize from '@/hooks/useWindowSize';
@ -30,6 +31,9 @@ const COLUMN_DEFINITION_HIDE_THRESHOLD = 1000;
const COLUMN_TYPE_HIDE_THRESHOLD = 1200;
const COLUMN_CONVENTION_HIDE_THRESHOLD = 1800;
const COMMENT_MAX_SYMBOLS = 100;
const DEFINITION_MAX_SYMBOLS = 120;
const columnHelper = createColumnHelper<IConstituenta>();
function TableRSList({
@ -111,7 +115,7 @@ function TableRSList({
size: 1000,
minSize: 200,
maxSize: 1000,
cell: props => <div className='text-xs text-pretty'>{props.getValue()}</div>
cell: props => <TextContent text={props.getValue()} maxLength={DEFINITION_MAX_SYMBOLS} />
}),
columnHelper.accessor('convention', {
id: 'convention',
@ -120,7 +124,7 @@ function TableRSList({
minSize: 100,
maxSize: 500,
enableHiding: true,
cell: props => <div className='text-xs text-pretty'>{props.getValue()}</div>
cell: props => <TextContent text={props.getValue()} maxLength={COMMENT_MAX_SYMBOLS} />
})
],
[colors]

View File

@ -66,6 +66,21 @@ export function applyPattern(text: string, mapping: Record<string, string>, patt
return output;
}
/**
* Truncate text to max symbols. Add ellipsis if truncated.
*/
export function truncateText(text: string, maxSymbols: number): string {
if (text.length <= maxSymbols) {
return text;
}
const trimmedText = text.slice(0, maxSymbols);
const lastSpaceIndex = trimmedText.lastIndexOf(' ');
if (lastSpaceIndex === -1) {
return trimmedText + '...';
}
return trimmedText.slice(0, lastSpaceIndex) + '...';
}
/**
* Check if Axios response is html.
*/