Compare commits

...

7 Commits

Author SHA1 Message Date
Ivan
2a30661355 F: Implementing backend for synthesis operation
Some checks failed
Backend CI / build (3.12) (push) Has been cancelled
Frontend CI / build (22.x) (push) Has been cancelled
2024-07-31 18:09:08 +03:00
Ivan
6a21125e87 R: Move update-constituenta to RSForm 2024-07-31 14:01:39 +03:00
Ivan
2e19c6fa69 R: Simplify sync_text and transfer_term 2024-07-30 15:59:37 +03:00
Ivan
afd3f5f7e4 F: Prepare frontend for Synthesis execution 2024-07-29 23:20:17 +03:00
Ivan
0a7cfa1375 F: Implement Operation edit 2024-07-29 22:30:24 +03:00
Ivan
d3213211b5 F: Implement UI for synthesis 2024-07-29 16:55:48 +03:00
Ivan
336b61957b F: Implement input schema change UI 2024-07-28 21:29:46 +03:00
67 changed files with 2039 additions and 795 deletions

View File

@ -432,7 +432,8 @@ disable=too-many-public-methods,
missing-function-docstring, missing-function-docstring,
attribute-defined-outside-init, attribute-defined-outside-init,
ungrouped-imports, ungrouped-imports,
abstract-method abstract-method,
fixme
# Enable the message, report, category or checker with the given id(s). You can # Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option # either give multiple identifier separated by comma (,) or put this option

View File

@ -140,7 +140,7 @@ class LibraryItem(Model):
def _update_connected_operations(self): def _update_connected_operations(self):
# using method level import to prevent circular dependency # using method level import to prevent circular dependency
from apps.oss.models import Operation # pylint: disable=import-outside-toplevel from apps.oss.models import Operation # pylint: disable=import-outside-toplevel
operations = Operation.objects.filter(result__pk=self.pk, sync_text=True) operations = Operation.objects.filter(result__pk=self.pk)
if not operations.exists(): if not operations.exists():
return return
for operation in operations: for operation in operations:

View File

@ -21,7 +21,7 @@ class ArgumentAdmin(admin.ModelAdmin):
class SynthesisSubstitutionAdmin(admin.ModelAdmin): class SynthesisSubstitutionAdmin(admin.ModelAdmin):
''' Admin model: Substitutions as part of Synthesis operation. ''' ''' Admin model: Substitutions as part of Synthesis operation. '''
ordering = ['operation'] ordering = ['operation']
list_display = ['id', 'operation', 'original', 'substitution', 'transfer_term'] list_display = ['id', 'operation', 'original', 'substitution']
search_fields = ['id', 'operation', 'original', 'substitution'] search_fields = ['id', 'operation', 'original', 'substitution']

View File

@ -0,0 +1,17 @@
# Generated by Django 5.0.7 on 2024-07-30 07:21
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('oss', '0002_inheritance'),
]
operations = [
migrations.RemoveField(
model_name='operation',
name='sync_text',
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 5.0.7 on 2024-07-30 07:36
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('oss', '0003_remove_operation_sync_text'),
]
operations = [
migrations.RemoveField(
model_name='substitution',
name='transfer_term',
),
]

View File

@ -2,15 +2,18 @@
from django.db.models import ( from django.db.models import (
CASCADE, CASCADE,
SET_NULL, SET_NULL,
BooleanField,
CharField, CharField,
FloatField, FloatField,
ForeignKey, ForeignKey,
Model, Model,
QuerySet,
TextChoices, TextChoices,
TextField TextField
) )
from .Argument import Argument
from .Substitution import Substitution
class OperationType(TextChoices): class OperationType(TextChoices):
''' Type of operation. ''' ''' Type of operation. '''
@ -39,10 +42,6 @@ class Operation(Model):
on_delete=SET_NULL, on_delete=SET_NULL,
related_name='producer' related_name='producer'
) )
sync_text: BooleanField = BooleanField(
verbose_name='Синхронизация',
default=True
)
alias: CharField = CharField( alias: CharField = CharField(
verbose_name='Шифр', verbose_name='Шифр',
@ -74,3 +73,11 @@ class Operation(Model):
def __str__(self) -> str: def __str__(self) -> str:
return f'Операция {self.alias}' return f'Операция {self.alias}'
def getArguments(self) -> QuerySet[Argument]:
''' Operation arguments. '''
return Argument.objects.filter(operation=self)
def getSubstitutions(self) -> QuerySet[Substitution]:
''' Operation substitutions. '''
return Substitution.objects.filter(operation=self)

View File

@ -5,10 +5,12 @@ from django.core.exceptions import ValidationError
from django.db import transaction from django.db import transaction
from django.db.models import QuerySet from django.db.models import QuerySet
from apps.library.models import LibraryItem, LibraryItemType from apps.library.models import Editor, LibraryItem, LibraryItemType
from apps.rsform.models import RSForm
from shared import messages as msg from shared import messages as msg
from .Argument import Argument from .Argument import Argument
from .Inheritance import Inheritance
from .Operation import Operation from .Operation import Operation
from .Substitution import Substitution from .Substitution import Substitution
@ -76,8 +78,8 @@ class OperationSchema:
''' Delete operation. ''' ''' Delete operation. '''
operation.delete() operation.delete()
# deal with attached schema # TODO: deal with attached schema
# trigger on_change effects # TODO: trigger on_change effects
self.save() self.save()
@ -86,53 +88,124 @@ class OperationSchema:
''' Set input schema for operation. ''' ''' Set input schema for operation. '''
if schema == target.result: if schema == target.result:
return return
if schema: target.result = schema
if schema is not None:
target.result = schema target.result = schema
target.alias = schema.alias target.alias = schema.alias
target.title = schema.title target.title = schema.title
target.comment = schema.comment target.comment = schema.comment
else:
target.result = None
target.save() target.save()
# trigger on_change effects # TODO: trigger on_change effects
self.save() self.save()
@transaction.atomic @transaction.atomic
def add_argument(self, operation: Operation, argument: Operation) -> Optional[Argument]: def set_arguments(self, operation: Operation, arguments: list[Operation]):
''' Add Argument to operation. ''' ''' Set arguments to operation. '''
if Argument.objects.filter(operation=operation, argument=argument).exists(): processed: list[Operation] = []
return None changed = False
result = Argument.objects.create(operation=operation, argument=argument) for current in operation.getArguments():
self.save() if current.argument not in arguments:
return result changed = True
current.delete()
@transaction.atomic else:
def clear_arguments(self, target: Operation): processed.append(current.argument)
''' Clear all arguments for operation. ''' for arg in arguments:
if not Argument.objects.filter(operation=target).exists(): if arg not in processed:
changed = True
processed.append(arg)
Argument.objects.create(operation=operation, argument=arg)
if not changed:
return return
# TODO: trigger on_change effects
Argument.objects.filter(operation=target).delete()
Substitution.objects.filter(operation=target).delete()
# trigger on_change effects
self.save() self.save()
@transaction.atomic @transaction.atomic
def set_substitutions(self, target: Operation, substitutes: list[dict]): def set_substitutions(self, target: Operation, substitutes: list[dict]):
''' Clear all arguments for operation. ''' ''' Clear all arguments for operation. '''
Substitution.objects.filter(operation=target).delete() processed: list[dict] = []
for sub in substitutes: changed = False
Substitution.objects.create(
operation=target,
original=sub['original'],
substitution=sub['substitution'],
transfer_term=sub['transfer_term']
)
# trigger on_change effects for current in target.getSubstitutions():
subs = [
x for x in substitutes
if x['original'] == current.original and x['substitution'] == current.substitution
]
if len(subs) == 0:
changed = True
current.delete()
else:
processed.append(subs[0])
for sub in substitutes:
if sub not in processed:
changed = True
Substitution.objects.create(
operation=target,
original=sub['original'],
substitution=sub['substitution']
)
if not changed:
return
# TODO: trigger on_change effects
self.save() self.save()
@transaction.atomic
def create_input(self, operation: Operation) -> RSForm:
''' Create input RSForm. '''
schema = RSForm.create(
owner=self.model.owner,
alias=operation.alias,
title=operation.title,
comment=operation.comment,
visible=False,
access_policy=self.model.access_policy,
location=self.model.location
)
Editor.set(schema.model, self.model.editors())
operation.result = schema.model
operation.save()
self.save()
return schema
@transaction.atomic
def execute_operation(self, operation: Operation) -> bool:
''' Execute target operation. '''
schemas: list[LibraryItem] = [arg.argument.result for arg in operation.getArguments()]
if None in schemas:
return False
substitutions = operation.getSubstitutions()
receiver = self.create_input(operation)
parents: dict = {}
children: dict = {}
for operand in schemas:
schema = RSForm(operand)
items = list(schema.constituents())
new_items = receiver.insert_copy(items)
for (i, cst) in enumerate(new_items):
parents[cst.pk] = items[i]
children[items[i].pk] = cst
for sub in substitutions:
original = children[sub.original.pk]
replacement = children[sub.substitution.pk]
receiver.substitute(original, replacement)
# TODO: remove duplicates from diamond
for cst in receiver.constituents():
parent = parents.get(cst.id)
assert parent is not None
Inheritance.objects.create(
child=cst,
parent=parent
)
receiver.restore_order()
receiver.reset_aliases()
self.save()
return True

View File

@ -1,5 +1,5 @@
''' Models: Synthesis Substitution. ''' ''' Models: Synthesis Substitution. '''
from django.db.models import CASCADE, BooleanField, ForeignKey, Model from django.db.models import CASCADE, ForeignKey, Model
class Substitution(Model): class Substitution(Model):
@ -22,10 +22,6 @@ class Substitution(Model):
on_delete=CASCADE, on_delete=CASCADE,
related_name='as_substitute' related_name='as_substitute'
) )
transfer_term: BooleanField = BooleanField(
verbose_name='Перенос термина',
default=False
)
class Meta: class Meta:
''' Model metadata. ''' ''' Model metadata. '''

View File

@ -6,6 +6,8 @@ from .data_access import (
OperationCreateSerializer, OperationCreateSerializer,
OperationSchemaSerializer, OperationSchemaSerializer,
OperationSerializer, OperationSerializer,
OperationTargetSerializer OperationTargetSerializer,
OperationUpdateSerializer,
SetOperationInputSerializer
) )
from .responses import NewOperationResponse, NewSchemaResponse from .responses import NewOperationResponse, NewSchemaResponse

View File

@ -21,7 +21,6 @@ class SubstitutionExSerializer(serializers.Serializer):
operation = serializers.IntegerField() operation = serializers.IntegerField()
original = serializers.IntegerField() original = serializers.IntegerField()
substitution = serializers.IntegerField() substitution = serializers.IntegerField()
transfer_term = serializers.BooleanField()
original_alias = serializers.CharField() original_alias = serializers.CharField()
original_term = serializers.CharField() original_term = serializers.CharField()
substitution_alias = serializers.CharField() substitution_alias = serializers.CharField()

View File

@ -5,8 +5,10 @@ from django.db.models import F
from rest_framework import serializers from rest_framework import serializers
from rest_framework.serializers import PrimaryKeyRelatedField as PKField from rest_framework.serializers import PrimaryKeyRelatedField as PKField
from apps.library.models import LibraryItem from apps.library.models import LibraryItem, LibraryItemType
from apps.library.serializers import LibraryItemDetailsSerializer from apps.library.serializers import LibraryItemDetailsSerializer
from apps.rsform.models import Constituenta
from apps.rsform.serializers import SubstitutionSerializerBase
from shared import messages as msg from shared import messages as msg
from ..models import Argument, Operation, OperationSchema, OperationType from ..models import Argument, Operation, OperationSchema, OperationType
@ -32,7 +34,7 @@ class ArgumentSerializer(serializers.ModelSerializer):
class OperationCreateSerializer(serializers.Serializer): class OperationCreateSerializer(serializers.Serializer):
''' Serializer: Operation creation. ''' ''' Serializer: Operation creation. '''
class OperationData(serializers.ModelSerializer): class OperationCreateData(serializers.ModelSerializer):
''' Serializer: Operation creation data. ''' ''' Serializer: Operation creation data. '''
alias = serializers.CharField() alias = serializers.CharField()
operation_type = serializers.ChoiceField(OperationType.choices) operation_type = serializers.ChoiceField(OperationType.choices)
@ -41,18 +43,83 @@ class OperationCreateSerializer(serializers.Serializer):
''' serializer metadata. ''' ''' serializer metadata. '''
model = Operation model = Operation
fields = \ fields = \
'alias', 'operation_type', 'title', 'sync_text', \ 'alias', 'operation_type', 'title', \
'comment', 'result', 'position_x', 'position_y' 'comment', 'result', 'position_x', 'position_y'
create_schema = serializers.BooleanField(default=False, required=False) create_schema = serializers.BooleanField(default=False, required=False)
item_data = OperationData() item_data = OperationCreateData()
arguments = PKField(many=True, queryset=Operation.objects.all(), required=False) arguments = PKField(many=True, queryset=Operation.objects.all(), required=False)
positions = serializers.ListField( positions = serializers.ListField(
child=OperationPositionSerializer(), child=OperationPositionSerializer(),
default=[] default=[]
) )
class OperationUpdateSerializer(serializers.Serializer):
''' Serializer: Operation creation. '''
class OperationUpdateData(serializers.ModelSerializer):
''' Serializer: Operation creation data. '''
class Meta:
''' serializer metadata. '''
model = Operation
fields = 'alias', 'title', 'comment'
target = PKField(many=False, queryset=Operation.objects.all())
item_data = OperationUpdateData()
arguments = PKField(many=True, queryset=Operation.objects.all(), required=False)
substitutions = serializers.ListField(
child=SubstitutionSerializerBase(),
required=False
)
positions = serializers.ListField(
child=OperationPositionSerializer(),
default=[]
)
def validate(self, attrs):
if 'arguments' not in attrs:
return attrs
oss = cast(LibraryItem, self.context['oss'])
for operation in attrs['arguments']:
if operation.oss != oss:
raise serializers.ValidationError({
'arguments': msg.operationNotInOSS(oss.title)
})
if 'substitutions' not in attrs:
return attrs
schemas = [arg.result.pk for arg in attrs['arguments'] if arg.result is not None]
substitutions = attrs['substitutions']
to_delete = {x['original'].pk for x in substitutions}
deleted = set()
for item in substitutions:
original_cst = cast(Constituenta, item['original'])
substitution_cst = cast(Constituenta, item['substitution'])
if original_cst.schema.pk not in schemas:
raise serializers.ValidationError({
f'{original_cst.id}': msg.constituentaNotFromOperation()
})
if substitution_cst.schema.pk not in schemas:
raise serializers.ValidationError({
f'{substitution_cst.id}': msg.constituentaNotFromOperation()
})
if original_cst.pk in deleted or substitution_cst.pk in to_delete:
raise serializers.ValidationError({
f'{original_cst.id}': msg.substituteDouble(original_cst.alias)
})
if original_cst.schema == substitution_cst.schema:
raise serializers.ValidationError({
'alias': msg.substituteTrivial(original_cst.alias)
})
deleted.add(original_cst.pk)
return attrs
class OperationTargetSerializer(serializers.Serializer): class OperationTargetSerializer(serializers.Serializer):
''' Serializer: Delete operation. ''' ''' Serializer: Delete operation. '''
target = PKField(many=False, queryset=Operation.objects.all()) target = PKField(many=False, queryset=Operation.objects.all())
@ -66,9 +133,36 @@ class OperationTargetSerializer(serializers.Serializer):
operation = cast(Operation, attrs['target']) operation = cast(Operation, attrs['target'])
if oss and operation.oss != oss: if oss and operation.oss != oss:
raise serializers.ValidationError({ raise serializers.ValidationError({
f'{operation.id}': msg.operationNotOwned(oss.title) 'target': msg.operationNotInOSS(oss.title)
})
return attrs
class SetOperationInputSerializer(serializers.Serializer):
''' Serializer: Set input schema for operation. '''
target = PKField(many=False, queryset=Operation.objects.all())
input = PKField(
many=False,
queryset=LibraryItem.objects.filter(item_type=LibraryItemType.RSFORM),
allow_null=True,
default=None
)
positions = serializers.ListField(
child=OperationPositionSerializer(),
default=[]
)
def validate(self, attrs):
oss = cast(LibraryItem, self.context['oss'])
operation = cast(Operation, attrs['target'])
if oss and operation.oss != oss:
raise serializers.ValidationError({
'target': msg.operationNotInOSS(oss.title)
})
if operation.operation_type != OperationType.INPUT:
raise serializers.ValidationError({
'target': msg.operationNotInput(operation.alias)
}) })
self.instance = operation
return attrs return attrs
@ -103,7 +197,6 @@ class OperationSchemaSerializer(serializers.ModelSerializer):
'operation', 'operation',
'original', 'original',
'substitution', 'substitution',
'transfer_term',
original_alias=F('original__alias'), original_alias=F('original__alias'),
original_term=F('original__term_resolved'), original_term=F('original__term_resolved'),
substitution_alias=F('substitution__alias'), substitution_alias=F('substitution__alias'),

View File

@ -29,7 +29,6 @@ class TestOperation(TestCase):
self.assertEqual(self.operation.alias, 'KS1') self.assertEqual(self.operation.alias, 'KS1')
self.assertEqual(self.operation.title, '') self.assertEqual(self.operation.title, '')
self.assertEqual(self.operation.comment, '') self.assertEqual(self.operation.comment, '')
self.assertEqual(self.operation.sync_text, True)
self.assertEqual(self.operation.position_x, 0) self.assertEqual(self.operation.position_x, 0)
self.assertEqual(self.operation.position_y, 0) self.assertEqual(self.operation.position_y, 0)
@ -50,15 +49,6 @@ class TestOperation(TestCase):
self.assertEqual(self.operation.title, schema.model.title) self.assertEqual(self.operation.title, schema.model.title)
self.assertEqual(self.operation.comment, schema.model.comment) self.assertEqual(self.operation.comment, schema.model.comment)
self.operation.sync_text = False
self.operation.save()
schema.model.alias = 'KS3'
schema.save()
self.operation.refresh_from_db()
self.assertEqual(self.operation.result, schema.model)
self.assertNotEqual(self.operation.alias, schema.model.alias)
def test_sync_from_library_item(self): def test_sync_from_library_item(self):
schema = LibraryItem.objects.create(alias=self.operation.alias, item_type=LibraryItemType.RSFORM) schema = LibraryItem.objects.create(alias=self.operation.alias, item_type=LibraryItemType.RSFORM)
self.operation.result = schema self.operation.result = schema

View File

@ -47,8 +47,7 @@ class TestSynthesisSubstitution(TestCase):
self.substitution = Substitution.objects.create( self.substitution = Substitution.objects.create(
operation=self.operation3, operation=self.operation3,
original=self.ks1x1, original=self.ks1x1,
substitution=self.ks2x1, substitution=self.ks2x1
transfer_term=False
) )

View File

@ -2,7 +2,7 @@
from rest_framework import status from rest_framework import status
from apps.library.models import AccessPolicy, LibraryItem, LibraryItemType, LocationHead from apps.library.models import AccessPolicy, Editor, LibraryItem, LibraryItemType, LocationHead
from apps.oss.models import Operation, OperationSchema, OperationType from apps.oss.models import Operation, OperationSchema, OperationType
from apps.rsform.models import RSForm from apps.rsform.models import RSForm
from shared.EndpointTester import EndpointTester, decl_endpoint from shared.EndpointTester import EndpointTester, decl_endpoint
@ -23,10 +23,27 @@ class TestOssViewset(EndpointTester):
def populateData(self): def populateData(self):
self.ks1 = RSForm.create(alias='KS1', title='Test1', owner=self.user) self.ks1 = RSForm.create(
self.ks1x1 = self.ks1.insert_new('X1', term_resolved='X1_1') alias='KS1',
self.ks2 = RSForm.create(alias='KS2', title='Test2', owner=self.user) title='Test1',
self.ks2x1 = self.ks2.insert_new('X2', term_resolved='X1_2') owner=self.user
)
self.ks1x1 = self.ks1.insert_new(
'X1',
term_raw='X1_1',
term_resolved='X1_1'
)
self.ks2 = RSForm.create(
alias='KS2',
title='Test2',
owner=self.user
)
self.ks2x1 = self.ks2.insert_new(
'X2',
term_raw='X1_2',
term_resolved='X1_2'
)
self.operation1 = self.owned.create_operation( self.operation1 = self.owned.create_operation(
alias='1', alias='1',
operation_type=OperationType.INPUT, operation_type=OperationType.INPUT,
@ -41,12 +58,10 @@ class TestOssViewset(EndpointTester):
alias='3', alias='3',
operation_type=OperationType.SYNTHESIS operation_type=OperationType.SYNTHESIS
) )
self.owned.add_argument(self.operation3, self.operation1) self.owned.set_arguments(self.operation3, [self.operation1, self.operation2])
self.owned.add_argument(self.operation3, self.operation2)
self.owned.set_substitutions(self.operation3, [{ self.owned.set_substitutions(self.operation3, [{
'original': self.ks1x1, 'original': self.ks1x1,
'substitution': self.ks2x1, 'substitution': self.ks2x1
'transfer_term': False
}]) }])
@decl_endpoint('/api/oss/{item}/details', method='get') @decl_endpoint('/api/oss/{item}/details', method='get')
@ -72,7 +87,6 @@ class TestOssViewset(EndpointTester):
self.assertEqual(sub['operation'], self.operation3.pk) self.assertEqual(sub['operation'], self.operation3.pk)
self.assertEqual(sub['original'], self.ks1x1.pk) self.assertEqual(sub['original'], self.ks1x1.pk)
self.assertEqual(sub['substitution'], self.ks2x1.pk) self.assertEqual(sub['substitution'], self.ks2x1.pk)
self.assertEqual(sub['transfer_term'], False)
self.assertEqual(sub['original_alias'], self.ks1x1.alias) self.assertEqual(sub['original_alias'], self.ks1x1.alias)
self.assertEqual(sub['original_term'], self.ks1x1.term_resolved) self.assertEqual(sub['original_term'], self.ks1x1.term_resolved)
self.assertEqual(sub['substitution_alias'], self.ks2x1.alias) self.assertEqual(sub['substitution_alias'], self.ks2x1.alias)
@ -135,7 +149,6 @@ class TestOssViewset(EndpointTester):
'alias': 'Test3', 'alias': 'Test3',
'title': 'Test title', 'title': 'Test title',
'comment': 'Тест кириллицы', 'comment': 'Тест кириллицы',
'sync_text': False,
'position_x': 1, 'position_x': 1,
'position_y': 1, 'position_y': 1,
}, },
@ -160,7 +173,6 @@ class TestOssViewset(EndpointTester):
self.assertEqual(new_operation['comment'], data['item_data']['comment']) self.assertEqual(new_operation['comment'], data['item_data']['comment'])
self.assertEqual(new_operation['position_x'], data['item_data']['position_x']) self.assertEqual(new_operation['position_x'], data['item_data']['position_x'])
self.assertEqual(new_operation['position_y'], data['item_data']['position_y']) self.assertEqual(new_operation['position_y'], data['item_data']['position_y'])
self.assertEqual(new_operation['sync_text'], data['item_data']['sync_text'])
self.assertEqual(new_operation['result'], None) self.assertEqual(new_operation['result'], None)
self.operation1.refresh_from_db() self.operation1.refresh_from_db()
self.assertEqual(self.operation1.position_x, data['positions'][0]['position_x']) self.assertEqual(self.operation1.position_x, data['positions'][0]['position_x'])
@ -208,6 +220,7 @@ class TestOssViewset(EndpointTester):
@decl_endpoint('/api/oss/{item}/create-operation', method='post') @decl_endpoint('/api/oss/{item}/create-operation', method='post')
def test_create_operation_schema(self): def test_create_operation_schema(self):
self.populateData() self.populateData()
Editor.add(self.owned.model, self.user2)
data = { data = {
'item_data': { 'item_data': {
'alias': 'Test4', 'alias': 'Test4',
@ -228,6 +241,7 @@ class TestOssViewset(EndpointTester):
self.assertEqual(schema.visible, False) self.assertEqual(schema.visible, False)
self.assertEqual(schema.access_policy, self.owned.model.access_policy) self.assertEqual(schema.access_policy, self.owned.model.access_policy)
self.assertEqual(schema.location, self.owned.model.location) self.assertEqual(schema.location, self.owned.model.location)
self.assertIn(self.user2, schema.editors())
@decl_endpoint('/api/oss/{item}/delete-operation', method='patch') @decl_endpoint('/api/oss/{item}/delete-operation', method='patch')
def test_delete_operation(self): def test_delete_operation(self):
@ -273,13 +287,11 @@ class TestOssViewset(EndpointTester):
self.operation1.result = None self.operation1.result = None
self.operation1.comment = 'TestComment' self.operation1.comment = 'TestComment'
self.operation1.title = 'TestTitle' self.operation1.title = 'TestTitle'
self.operation1.sync_text = False
self.operation1.save() self.operation1.save()
response = self.executeOK(data=data) response = self.executeOK(data=data)
self.operation1.refresh_from_db() self.operation1.refresh_from_db()
new_schema = response.data['new_schema'] new_schema = response.data['new_schema']
self.assertEqual(self.operation1.sync_text, True)
self.assertEqual(new_schema['id'], self.operation1.result.pk) self.assertEqual(new_schema['id'], self.operation1.result.pk)
self.assertEqual(new_schema['alias'], self.operation1.alias) self.assertEqual(new_schema['alias'], self.operation1.alias)
self.assertEqual(new_schema['title'], self.operation1.title) self.assertEqual(new_schema['title'], self.operation1.title)
@ -287,3 +299,178 @@ class TestOssViewset(EndpointTester):
data['target'] = self.operation3.pk data['target'] = self.operation3.pk
self.executeBadData(data=data) self.executeBadData(data=data)
@decl_endpoint('/api/oss/{item}/set-input', method='patch')
def test_set_input_null(self):
self.populateData()
self.executeBadData(item=self.owned_id)
data = {
'positions': []
}
self.executeBadData(data=data)
data['target'] = self.operation1.pk
data['input'] = None
data['target'] = self.operation1.pk
self.toggle_admin(True)
self.executeBadData(data=data, item=self.unowned_id)
self.logout()
self.executeForbidden(data=data, item=self.owned_id)
self.login()
response = self.executeOK(data=data)
self.operation1.refresh_from_db()
self.assertEqual(self.operation1.result, None)
data['input'] = self.ks1.model.pk
self.ks1.model.alias = 'Test42'
self.ks1.model.title = 'Test421'
self.ks1.model.comment = 'TestComment42'
self.ks1.save()
response = self.executeOK(data=data)
self.operation1.refresh_from_db()
self.assertEqual(self.operation1.result, self.ks1.model)
self.assertEqual(self.operation1.alias, self.ks1.model.alias)
self.assertEqual(self.operation1.title, self.ks1.model.title)
self.assertEqual(self.operation1.comment, self.ks1.model.comment)
@decl_endpoint('/api/oss/{item}/set-input', method='patch')
def test_set_input_change_schema(self):
self.populateData()
self.operation2.result = None
data = {
'positions': [],
'target': self.operation1.pk,
'input': self.ks2.model.pk
}
response = self.executeOK(data=data, item=self.owned_id)
self.operation2.refresh_from_db()
self.assertEqual(self.operation2.result, self.ks2.model)
@decl_endpoint('/api/oss/{item}/update-operation', method='patch')
def test_update_operation(self):
self.populateData()
self.executeBadData(item=self.owned_id)
ks3 = RSForm.create(alias='KS3', title='Test3', owner=self.user)
ks3x1 = ks3.insert_new('X1', term_resolved='X1_1')
data = {
'target': self.operation3.pk,
'item_data': {
'alias': 'Test3 mod',
'title': 'Test title mod',
'comment': 'Comment mod'
},
'positions': [],
'arguments': [self.operation1.pk, self.operation2.pk],
'substitutions': [
{
'original': self.ks1x1.pk,
'substitution': ks3x1.pk
}
]
}
self.executeBadData(data=data)
data['substitutions'][0]['substitution'] = self.ks2x1.pk
self.toggle_admin(True)
self.executeBadData(data=data, item=self.unowned_id)
self.logout()
self.executeForbidden(data=data, item=self.owned_id)
self.login()
response = self.executeOK(data=data)
self.operation3.refresh_from_db()
self.assertEqual(self.operation3.alias, data['item_data']['alias'])
self.assertEqual(self.operation3.title, data['item_data']['title'])
self.assertEqual(self.operation3.comment, data['item_data']['comment'])
self.assertEqual(set([argument.pk for argument in self.operation3.getArguments()]), set(data['arguments']))
sub = self.operation3.getSubstitutions()[0]
self.assertEqual(sub.original.pk, data['substitutions'][0]['original'])
self.assertEqual(sub.substitution.pk, data['substitutions'][0]['substitution'])
@decl_endpoint('/api/oss/{item}/update-operation', method='patch')
def test_update_operation_sync(self):
self.populateData()
self.executeBadData(item=self.owned_id)
data = {
'target': self.operation1.pk,
'item_data': {
'alias': 'Test3 mod',
'title': 'Test title mod',
'comment': 'Comment mod'
},
'positions': [],
}
response = self.executeOK(data=data)
self.operation1.refresh_from_db()
self.assertEqual(self.operation1.alias, data['item_data']['alias'])
self.assertEqual(self.operation1.title, data['item_data']['title'])
self.assertEqual(self.operation1.comment, data['item_data']['comment'])
self.assertEqual(self.operation1.result.alias, data['item_data']['alias'])
self.assertEqual(self.operation1.result.title, data['item_data']['title'])
self.assertEqual(self.operation1.result.comment, data['item_data']['comment'])
@decl_endpoint('/api/oss/{item}/update-operation', method='patch')
def test_update_operation_invalid_substitution(self):
self.populateData()
self.ks1x2 = self.ks1.insert_new('X2')
data = {
'target': self.operation3.pk,
'item_data': {
'alias': 'Test3 mod',
'title': 'Test title mod',
'comment': 'Comment mod'
},
'positions': [],
'arguments': [self.operation1.pk, self.operation2.pk],
'substitutions': [
{
'original': self.ks1x1.pk,
'substitution': self.ks2x1.pk
},
{
'original': self.ks2x1.pk,
'substitution': self.ks1x2.pk
}
]
}
self.executeBadData(data=data, item=self.owned_id)
@decl_endpoint('/api/oss/{item}/execute-operation', method='post')
def test_execute_operation(self):
self.populateData()
self.executeBadData(item=self.owned_id)
data = {
'positions': [],
'target': self.operation1.pk
}
self.executeBadData(data=data)
data['target'] = self.operation3.pk
self.toggle_admin(True)
self.executeBadData(data=data, item=self.unowned_id)
self.logout()
self.executeForbidden(data=data, item=self.owned_id)
self.login()
self.executeOK(data=data)
self.operation3.refresh_from_db()
schema = self.operation3.result
self.assertEqual(schema.alias, self.operation3.alias)
self.assertEqual(schema.comment, self.operation3.comment)
self.assertEqual(schema.title, self.operation3.title)
self.assertEqual(schema.visible, False)
items = list(RSForm(schema).constituents())
self.assertEqual(len(items), 1)
self.assertEqual(items[0].alias, 'X1')
self.assertEqual(items[0].term_resolved, self.ks2x1.term_resolved)

View File

@ -35,7 +35,10 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
'create_operation', 'create_operation',
'delete_operation', 'delete_operation',
'update_positions', 'update_positions',
'create_input' 'create_input',
'set_input',
'update_operation',
'execute_operation'
]: ]:
permission_list = [permissions.ItemEditor] permission_list = [permissions.ItemEditor]
elif self.action in ['details']: elif self.action in ['details']:
@ -100,25 +103,14 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
oss = m.OperationSchema(self.get_object()) oss = m.OperationSchema(self.get_object())
with transaction.atomic(): with transaction.atomic():
oss.update_positions(serializer.validated_data['positions']) oss.update_positions(serializer.validated_data['positions'])
data: dict = serializer.validated_data['item_data'] new_operation = oss.create_operation(**serializer.validated_data['item_data'])
if data['operation_type'] == m.OperationType.INPUT and serializer.validated_data['create_schema']: if new_operation.operation_type == m.OperationType.INPUT and serializer.validated_data['create_schema']:
schema = LibraryItem.objects.create( oss.create_input(new_operation)
item_type=LibraryItemType.RSFORM,
owner=oss.model.owner,
alias=data['alias'],
title=data['title'],
comment=data['comment'],
visible=False,
access_policy=oss.model.access_policy,
location=oss.model.location
)
data['result'] = schema
new_operation = oss.create_operation(**data)
if new_operation.operation_type != m.OperationType.INPUT and 'arguments' in serializer.validated_data: if new_operation.operation_type != m.OperationType.INPUT and 'arguments' in serializer.validated_data:
for argument in serializer.validated_data['arguments']: oss.set_arguments(
oss.add_argument(operation=new_operation, argument=argument) operation=new_operation,
arguments=serializer.validated_data['arguments']
oss.refresh_from_db() )
return Response( return Response(
status=c.HTTP_201_CREATED, status=c.HTTP_201_CREATED,
data={ data={
@ -152,7 +144,6 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
oss.update_positions(serializer.validated_data['positions']) oss.update_positions(serializer.validated_data['positions'])
oss.delete_operation(serializer.validated_data['target']) oss.delete_operation(serializer.validated_data['target'])
oss.refresh_from_db()
return Response( return Response(
status=c.HTTP_200_OK, status=c.HTTP_200_OK,
data=s.OperationSchemaSerializer(oss.model).data data=s.OperationSchemaSerializer(oss.model).data
@ -191,25 +182,127 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
oss = m.OperationSchema(self.get_object()) oss = m.OperationSchema(self.get_object())
with transaction.atomic(): with transaction.atomic():
oss.update_positions(serializer.validated_data['positions']) oss.update_positions(serializer.validated_data['positions'])
schema = LibraryItem.objects.create( schema = oss.create_input(operation)
item_type=LibraryItemType.RSFORM,
owner=oss.model.owner,
alias=operation.alias,
title=operation.title,
comment=operation.comment,
visible=False,
access_policy=oss.model.access_policy,
location=oss.model.location
)
operation.result = schema
operation.sync_text = True
operation.save()
oss.refresh_from_db()
return Response( return Response(
status=c.HTTP_200_OK, status=c.HTTP_200_OK,
data={ data={
'new_schema': LibraryItemSerializer(schema).data, 'new_schema': LibraryItemSerializer(schema.model).data,
'oss': s.OperationSchemaSerializer(oss.model).data 'oss': s.OperationSchemaSerializer(oss.model).data
} }
) )
@extend_schema(
summary='set input schema for target operation',
tags=['OSS'],
request=s.SetOperationInputSerializer(),
responses={
c.HTTP_200_OK: s.OperationSchemaSerializer,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='set-input')
def set_input(self, request: Request, pk):
''' Set input schema for target operation. '''
serializer = s.SetOperationInputSerializer(
data=request.data,
context={'oss': self.get_object()}
)
serializer.is_valid(raise_exception=True)
operation: m.Operation = cast(m.Operation, serializer.validated_data['target'])
oss = m.OperationSchema(self.get_object())
with transaction.atomic():
oss.update_positions(serializer.validated_data['positions'])
oss.set_input(operation, serializer.validated_data['input'])
return Response(
status=c.HTTP_200_OK,
data=s.OperationSchemaSerializer(oss.model).data
)
@extend_schema(
summary='update operation',
tags=['OSS'],
request=s.OperationUpdateSerializer(),
responses={
c.HTTP_200_OK: s.OperationSchemaSerializer,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='update-operation')
def update_operation(self, request: Request, pk):
''' Update operation arguments and parameters. '''
serializer = s.OperationUpdateSerializer(
data=request.data,
context={'oss': self.get_object()}
)
serializer.is_valid(raise_exception=True)
operation: m.Operation = cast(m.Operation, serializer.validated_data['target'])
oss = m.OperationSchema(self.get_object())
with transaction.atomic():
oss.update_positions(serializer.validated_data['positions'])
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()
if operation.result is not None:
can_edit = permissions.can_edit_item(request.user, operation.result)
if can_edit:
operation.result.alias = operation.alias
operation.result.title = operation.title
operation.result.comment = operation.comment
operation.result.save()
if 'arguments' in serializer.validated_data:
oss.set_arguments(operation, serializer.validated_data['arguments'])
if 'substitutions' in serializer.validated_data:
oss.set_substitutions(operation, serializer.validated_data['substitutions'])
return Response(
status=c.HTTP_200_OK,
data=s.OperationSchemaSerializer(oss.model).data
)
@extend_schema(
summary='execute operation',
tags=['OSS'],
request=s.OperationTargetSerializer(),
responses={
c.HTTP_200_OK: s.OperationSchemaSerializer,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['post'], url_path='execute-operation')
def execute_operation(self, request: Request, pk):
''' Execute operation. '''
serializer = s.OperationTargetSerializer(
data=request.data,
context={'oss': self.get_object()}
)
serializer.is_valid(raise_exception=True)
operation: m.Operation = cast(m.Operation, serializer.validated_data['target'])
if operation.operation_type != m.OperationType.SYNTHESIS:
raise serializers.ValidationError({
'target': msg.operationNotSynthesis(operation.alias)
})
if operation.result is not None:
raise serializers.ValidationError({
'target': msg.operationResultNotEmpty(operation.alias)
})
oss = m.OperationSchema(self.get_object())
with transaction.atomic():
oss.update_positions(serializer.validated_data['positions'])
oss.execute_operation(operation)
return Response(
status=c.HTTP_200_OK,
data=s.OperationSchemaSerializer(oss.model).data
)

View File

@ -12,7 +12,6 @@ from django.db.models import (
TextChoices, TextChoices,
TextField TextField
) )
from django.urls import reverse
from ..utils import apply_pattern from ..utils import apply_pattern
@ -95,10 +94,6 @@ class Constituenta(Model):
verbose_name = 'Конституента' verbose_name = 'Конституента'
verbose_name_plural = 'Конституенты' verbose_name_plural = 'Конституенты'
def get_absolute_url(self):
''' URL access. '''
return reverse('constituenta-detail', kwargs={'pk': self.pk})
def __str__(self) -> str: def __str__(self) -> str:
return f'{self.alias}' return f'{self.alias}'

View File

@ -241,18 +241,12 @@ class RSForm:
def substitute( def substitute(
self, self,
original: Constituenta, original: Constituenta,
substitution: Constituenta, substitution: Constituenta
transfer_term: bool
): ):
''' Execute constituenta substitution. ''' ''' Execute constituenta substitution. '''
assert original.pk != substitution.pk assert original.pk != substitution.pk
mapping = {original.alias: substitution.alias} mapping = {original.alias: substitution.alias}
self.apply_mapping(mapping) self.apply_mapping(mapping)
if transfer_term:
substitution.term_raw = original.term_raw
substitution.term_forms = original.term_forms
substitution.term_resolved = original.term_resolved
substitution.save()
original.delete() original.delete()
self.on_term_change([substitution.id]) self.on_term_change([substitution.id])

View File

@ -19,7 +19,8 @@ from .data_access import (
CstTargetSerializer, CstTargetSerializer,
InlineSynthesisSerializer, InlineSynthesisSerializer,
RSFormParseSerializer, RSFormParseSerializer,
RSFormSerializer RSFormSerializer,
SubstitutionSerializerBase
) )
from .io_files import FileSerializer, RSFormTRSSerializer, RSFormUploadSerializer from .io_files import FileSerializer, RSFormTRSSerializer, RSFormUploadSerializer
from .io_pyconcept import PyConceptAdapter from .io_pyconcept import PyConceptAdapter

View File

@ -31,7 +31,7 @@ class CstSerializer(serializers.ModelSerializer):
''' serializer metadata. ''' ''' serializer metadata. '''
model = Constituenta model = Constituenta
fields = '__all__' fields = '__all__'
read_only_fields = ('id', '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: def update(self, instance: Constituenta, validated_data) -> Constituenta:
data = validated_data # Note: use alias for better code readability data = validated_data # Note: use alias for better code readability
@ -212,7 +212,7 @@ class CstTargetSerializer(serializers.Serializer):
cst = cast(Constituenta, attrs['target']) cst = cast(Constituenta, attrs['target'])
if schema and cst.schema != schema: if schema and cst.schema != schema:
raise serializers.ValidationError({ raise serializers.ValidationError({
f'{cst.id}': msg.constituentaNotOwned(schema.title) f'{cst.id}': msg.constituentaNotInRSform(schema.title)
}) })
if cst.cst_type not in [CstType.FUNCTION, CstType.STRUCTURED, CstType.TERM]: if cst.cst_type not in [CstType.FUNCTION, CstType.STRUCTURED, CstType.TERM]:
raise serializers.ValidationError({ raise serializers.ValidationError({
@ -234,7 +234,7 @@ class CstRenameSerializer(serializers.Serializer):
cst = cast(Constituenta, attrs['target']) cst = cast(Constituenta, attrs['target'])
if cst.schema != schema: if cst.schema != schema:
raise serializers.ValidationError({ raise serializers.ValidationError({
f'{cst.id}': msg.constituentaNotOwned(schema.title) f'{cst.id}': msg.constituentaNotInRSform(schema.title)
}) })
new_alias = self.initial_data['alias'] new_alias = self.initial_data['alias']
if cst.alias == new_alias: if cst.alias == new_alias:
@ -260,7 +260,7 @@ class CstListSerializer(serializers.Serializer):
for item in attrs['items']: for item in attrs['items']:
if item.schema != schema: if item.schema != schema:
raise serializers.ValidationError({ raise serializers.ValidationError({
f'{item.id}': msg.constituentaNotOwned(schema.title) f'{item.id}': msg.constituentaNotInRSform(schema.title)
}) })
return attrs return attrs
@ -270,17 +270,16 @@ class CstMoveSerializer(CstListSerializer):
move_to = serializers.IntegerField() move_to = serializers.IntegerField()
class CstSubstituteSerializerBase(serializers.Serializer): class SubstitutionSerializerBase(serializers.Serializer):
''' Serializer: Basic substitution. ''' ''' Serializer: Basic substitution. '''
original = PKField(many=False, queryset=Constituenta.objects.all()) original = PKField(many=False, queryset=Constituenta.objects.all())
substitution = PKField(many=False, queryset=Constituenta.objects.all()) substitution = PKField(many=False, queryset=Constituenta.objects.all())
transfer_term = serializers.BooleanField(required=False, default=False)
class CstSubstituteSerializer(serializers.Serializer): class CstSubstituteSerializer(serializers.Serializer):
''' Serializer: Constituenta substitution. ''' ''' Serializer: Constituenta substitution. '''
substitutions = serializers.ListField( substitutions = serializers.ListField(
child=CstSubstituteSerializerBase(), child=SubstitutionSerializerBase(),
min_length=1 min_length=1
) )
@ -300,11 +299,11 @@ class CstSubstituteSerializer(serializers.Serializer):
}) })
if original_cst.schema != schema: if original_cst.schema != schema:
raise serializers.ValidationError({ raise serializers.ValidationError({
'original': msg.constituentaNotOwned(schema.title) 'original': msg.constituentaNotInRSform(schema.title)
}) })
if substitution_cst.schema != schema: if substitution_cst.schema != schema:
raise serializers.ValidationError({ raise serializers.ValidationError({
'substitution': msg.constituentaNotOwned(schema.title) 'substitution': msg.constituentaNotInRSform(schema.title)
}) })
deleted.add(original_cst.pk) deleted.add(original_cst.pk)
return attrs return attrs
@ -316,7 +315,7 @@ class InlineSynthesisSerializer(serializers.Serializer):
source = PKField(many=False, queryset=LibraryItem.objects.all()) # type: ignore source = PKField(many=False, queryset=LibraryItem.objects.all()) # type: ignore
items = PKField(many=True, queryset=Constituenta.objects.all()) items = PKField(many=True, queryset=Constituenta.objects.all())
substitutions = serializers.ListField( substitutions = serializers.ListField(
child=CstSubstituteSerializerBase() child=SubstitutionSerializerBase()
) )
def validate(self, attrs): def validate(self, attrs):
@ -325,14 +324,14 @@ class InlineSynthesisSerializer(serializers.Serializer):
schema_out = cast(LibraryItem, attrs['receiver']) schema_out = cast(LibraryItem, attrs['receiver'])
if user.is_anonymous or (schema_out.owner != user and not user.is_staff): if user.is_anonymous or (schema_out.owner != user and not user.is_staff):
raise PermissionDenied({ raise PermissionDenied({
'message': msg.schemaNotOwned(), 'message': msg.schemaForbidden(),
'object_id': schema_in.id 'object_id': schema_in.id
}) })
constituents = cast(list[Constituenta], attrs['items']) constituents = cast(list[Constituenta], attrs['items'])
for cst in constituents: for cst in constituents:
if cst.schema != schema_in: if cst.schema != schema_in:
raise serializers.ValidationError({ raise serializers.ValidationError({
f'{cst.id}': msg.constituentaNotOwned(schema_in.title) f'{cst.id}': msg.constituentaNotInRSform(schema_in.title)
}) })
deleted = set() deleted = set()
for item in attrs['substitutions']: for item in attrs['substitutions']:
@ -345,7 +344,7 @@ class InlineSynthesisSerializer(serializers.Serializer):
}) })
if substitution_cst.schema != schema_out: if substitution_cst.schema != schema_out:
raise serializers.ValidationError({ raise serializers.ValidationError({
f'{substitution_cst.id}': msg.constituentaNotOwned(schema_out.title) f'{substitution_cst.id}': msg.constituentaNotInRSform(schema_out.title)
}) })
else: else:
if substitution_cst not in constituents: if substitution_cst not in constituents:
@ -354,7 +353,7 @@ class InlineSynthesisSerializer(serializers.Serializer):
}) })
if original_cst.schema != schema_out: if original_cst.schema != schema_out:
raise serializers.ValidationError({ raise serializers.ValidationError({
f'{original_cst.id}': msg.constituentaNotOwned(schema_out.title) f'{original_cst.id}': msg.constituentaNotInRSform(schema_out.title)
}) })
if original_cst.pk in deleted: if original_cst.pk in deleted:
raise serializers.ValidationError({ raise serializers.ValidationError({

View File

@ -20,12 +20,6 @@ class TestConstituenta(TestCase):
self.assertEqual(str(cst), testStr) self.assertEqual(str(cst), testStr)
def test_url(self):
testStr = 'X1'
cst = Constituenta.objects.create(alias=testStr, schema=self.schema1.model, order=1, convention='Test')
self.assertEqual(cst.get_absolute_url(), f'/api/constituents/{cst.pk}')
def test_order_not_null(self): def test_order_not_null(self):
with self.assertRaises(IntegrityError): with self.assertRaises(IntegrityError):
Constituenta.objects.create(alias='X1', schema=self.schema1.model) Constituenta.objects.create(alias='X1', schema=self.schema1.model)

View File

@ -208,11 +208,11 @@ class TestRSForm(TestCase):
definition_formal=x1.alias definition_formal=x1.alias
) )
self.schema.substitute(x1, x2, True) self.schema.substitute(x1, x2)
x2.refresh_from_db() x2.refresh_from_db()
d1.refresh_from_db() d1.refresh_from_db()
self.assertEqual(self.schema.constituents().count(), 2) self.assertEqual(self.schema.constituents().count(), 2)
self.assertEqual(x2.term_raw, 'Test') self.assertEqual(x2.term_raw, 'Test2')
self.assertEqual(d1.definition_formal, x2.alias) self.assertEqual(d1.definition_formal, x2.alias)

View File

@ -1,6 +1,4 @@
''' Tests for REST API. ''' ''' Tests for REST API. '''
from .t_cctext import * from .t_cctext import *
from .t_constituents import *
from .t_operations import *
from .t_rsforms import * from .t_rsforms import *
from .t_rslang import * from .t_rslang import *

View File

@ -1,102 +0,0 @@
''' Testing API: Constituents. '''
from apps.rsform.models import Constituenta, CstType, RSForm
from shared.EndpointTester import EndpointTester, decl_endpoint
class TestConstituentaAPI(EndpointTester):
''' Testing Constituenta view. '''
def setUp(self):
super().setUp()
self.rsform_owned = RSForm.create(title='Test', alias='T1', owner=self.user)
self.rsform_unowned = RSForm.create(title='Test2', alias='T2')
self.cst1 = Constituenta.objects.create(
alias='X1',
cst_type=CstType.BASE,
schema=self.rsform_owned.model,
order=1,
convention='Test',
term_raw='Test1',
term_resolved='Test1R',
term_forms=[{'text': 'form1', 'tags': 'sing,datv'}])
self.cst2 = Constituenta.objects.create(
alias='X2',
cst_type=CstType.BASE,
schema=self.rsform_unowned.model,
order=1,
convention='Test1',
term_raw='Test2',
term_resolved='Test2R'
)
self.cst3 = Constituenta.objects.create(
alias='X3',
schema=self.rsform_owned.model,
order=2,
term_raw='Test3',
term_resolved='Test3',
definition_raw='Test1',
definition_resolved='Test2'
)
self.invalid_cst = self.cst3.pk + 1337
@decl_endpoint('/api/constituents/{item}', method='get')
def test_retrieve(self):
self.executeNotFound(item=self.invalid_cst)
response = self.executeOK(item=self.cst1.pk)
self.assertEqual(response.data['alias'], self.cst1.alias)
self.assertEqual(response.data['convention'], self.cst1.convention)
@decl_endpoint('/api/constituents/{item}', method='patch')
def test_partial_update(self):
data = {'convention': 'tt'}
self.executeForbidden(data=data, item=self.cst2.pk)
self.logout()
self.executeForbidden(data=data, item=self.cst1.pk)
self.login()
response = self.executeOK(data=data, item=self.cst1.pk)
self.cst1.refresh_from_db()
self.assertEqual(response.data['convention'], 'tt')
self.assertEqual(self.cst1.convention, 'tt')
self.executeOK(data=data, item=self.cst1.pk)
@decl_endpoint('/api/constituents/{item}', method='patch')
def test_update_resolved_no_refs(self):
data = {
'term_raw': 'New term',
'definition_raw': 'New def'
}
response = self.executeOK(data=data, item=self.cst3.pk)
self.cst3.refresh_from_db()
self.assertEqual(response.data['term_resolved'], 'New term')
self.assertEqual(self.cst3.term_resolved, 'New term')
self.assertEqual(response.data['definition_resolved'], 'New def')
self.assertEqual(self.cst3.definition_resolved, 'New def')
@decl_endpoint('/api/constituents/{item}', method='patch')
def test_update_resolved_refs(self):
data = {
'term_raw': '@{X1|nomn,sing}',
'definition_raw': '@{X1|nomn,sing} @{X1|sing,datv}'
}
response = self.executeOK(data=data, item=self.cst3.pk)
self.cst3.refresh_from_db()
self.assertEqual(self.cst3.term_resolved, self.cst1.term_resolved)
self.assertEqual(response.data['term_resolved'], self.cst1.term_resolved)
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/constituents/{item}', method='patch')
def test_readonly_cst_fields(self):
data = {'alias': 'X33', 'order': 10}
response = self.executeOK(data=data, item=self.cst1.pk)
self.assertEqual(response.data['alias'], 'X1')
self.assertEqual(response.data['alias'], self.cst1.alias)
self.assertEqual(response.data['order'], self.cst1.order)

View File

@ -1,82 +0,0 @@
''' Testing API: Operations. '''
from apps.rsform.models import Constituenta, CstType, RSForm
from shared.EndpointTester import EndpointTester, decl_endpoint
class TestInlineSynthesis(EndpointTester):
''' Testing Operations endpoints. '''
@decl_endpoint('/api/operations/inline-synthesis', method='patch')
def setUp(self):
super().setUp()
self.schema1 = RSForm.create(title='Test1', alias='T1', owner=self.user)
self.schema2 = RSForm.create(title='Test2', alias='T2', owner=self.user)
self.unowned = RSForm.create(title='Test3', alias='T3')
def test_inline_synthesis_inputs(self):
invalid_id = 1338
data = {
'receiver': self.unowned.model.pk,
'source': self.schema1.model.pk,
'items': [],
'substitutions': []
}
self.executeForbidden(data=data)
data['receiver'] = invalid_id
self.executeBadData(data=data)
data['receiver'] = self.schema1.model.pk
data['source'] = invalid_id
self.executeBadData(data=data)
data['source'] = self.schema1.model.pk
self.executeOK(data=data)
data['items'] = [invalid_id]
self.executeBadData(data=data)
def test_inline_synthesis(self):
ks1_x1 = self.schema1.insert_new('X1', term_raw='KS1X1') # -> delete
ks1_x2 = self.schema1.insert_new('X2', term_raw='KS1X2') # -> X2
ks1_s1 = self.schema1.insert_new('S1', definition_formal='X2', term_raw='KS1S1') # -> S1
ks1_d1 = self.schema1.insert_new('D1', definition_formal=r'S1\X1\X2') # -> D1
ks2_x1 = self.schema2.insert_new('X1', term_raw='KS2X1') # -> delete
ks2_x2 = self.schema2.insert_new('X2', term_raw='KS2X2') # -> X4
ks2_s1 = self.schema2.insert_new('S1', definition_formal='X2×X2', term_raw='KS2S1') # -> S2
ks2_d1 = self.schema2.insert_new('D1', definition_formal=r'S1\X1\X2') # -> D2
ks2_a1 = self.schema2.insert_new('A1', definition_formal='1=1') # -> not included in items
data = {
'receiver': self.schema1.model.pk,
'source': self.schema2.model.pk,
'items': [ks2_x1.pk, ks2_x2.pk, ks2_s1.pk, ks2_d1.pk],
'substitutions': [
{
'original': ks1_x1.pk,
'substitution': ks2_s1.pk,
'transfer_term': False
},
{
'original': ks2_x1.pk,
'substitution': ks1_s1.pk,
'transfer_term': True
}
]
}
response = self.executeOK(data=data)
result = {item['alias']: item for item in response.data['items']}
self.assertEqual(len(result), 6)
self.assertEqual(result['X2']['term_raw'], ks1_x2.term_raw)
self.assertEqual(result['X2']['order'], 1)
self.assertEqual(result['X4']['term_raw'], ks2_x2.term_raw)
self.assertEqual(result['X4']['order'], 2)
self.assertEqual(result['S1']['term_raw'], ks2_x1.term_raw)
self.assertEqual(result['S2']['term_raw'], ks2_s1.term_raw)
self.assertEqual(result['S1']['definition_formal'], 'X2')
self.assertEqual(result['S2']['definition_formal'], 'X4×X4')
self.assertEqual(result['D1']['definition_formal'], r'S1\S2\X2')
self.assertEqual(result['D2']['definition_formal'], r'S2\S1\X4')

View File

@ -272,43 +272,6 @@ class TestRSFormViewset(EndpointTester):
self.assertEqual(x1.cst_type, CstType.TERM) self.assertEqual(x1.cst_type, CstType.TERM)
@decl_endpoint('/api/rsforms/{item}/substitute', method='patch')
def test_substitute_single(self):
x1 = self.owned.insert_new(
alias='X1',
term_raw='Test1',
term_resolved='Test1',
term_forms=[{'text': 'form1', 'tags': 'sing,datv'}]
)
x2 = self.owned.insert_new(
alias='X2',
term_raw='Test2'
)
unowned = self.unowned.insert_new('X2')
data = {'substitutions': [{'original': x1.pk, 'substitution': unowned.pk, 'transfer_term': True}]}
self.executeForbidden(data=data, item=self.unowned_id)
self.executeBadData(data=data, item=self.owned_id)
data = {'substitutions': [{'original': unowned.pk, 'substitution': x1.pk, 'transfer_term': True}]}
self.executeBadData(data=data, item=self.owned_id)
data = {'substitutions': [{'original': x1.pk, 'substitution': x1.pk, 'transfer_term': True}]}
self.executeBadData(data=data, item=self.owned_id)
d1 = self.owned.insert_new(
alias='D1',
term_raw='@{X2|sing,datv}',
definition_formal='X1'
)
data = {'substitutions': [{'original': x1.pk, 'substitution': x2.pk, 'transfer_term': True}]}
response = self.executeOK(data=data, item=self.owned_id)
d1.refresh_from_db()
x2.refresh_from_db()
self.assertEqual(x2.term_raw, 'Test1')
self.assertEqual(d1.term_resolved, 'form1')
self.assertEqual(d1.definition_formal, 'X2')
@decl_endpoint('/api/rsforms/{item}/substitute', method='patch') @decl_endpoint('/api/rsforms/{item}/substitute', method='patch')
def test_substitute_multiple(self): def test_substitute_multiple(self):
self.set_params(item=self.owned_id) self.set_params(item=self.owned_id)
@ -327,13 +290,11 @@ class TestRSFormViewset(EndpointTester):
data = {'substitutions': [ data = {'substitutions': [
{ {
'original': x1.pk, 'original': x1.pk,
'substitution': d1.pk, 'substitution': d1.pk
'transfer_term': True
}, },
{ {
'original': x1.pk, 'original': x1.pk,
'substitution': d2.pk, 'substitution': d2.pk
'transfer_term': True
} }
]} ]}
self.executeBadData(data=data) self.executeBadData(data=data)
@ -341,13 +302,11 @@ class TestRSFormViewset(EndpointTester):
data = {'substitutions': [ data = {'substitutions': [
{ {
'original': x1.pk, 'original': x1.pk,
'substitution': d1.pk, 'substitution': d1.pk
'transfer_term': True
}, },
{ {
'original': x2.pk, 'original': x2.pk,
'substitution': d2.pk, 'substitution': d2.pk
'transfer_term': True
} }
]} ]}
response = self.executeOK(data=data, item=self.owned_id) response = self.executeOK(data=data, item=self.owned_id)
@ -523,3 +482,172 @@ class TestRSFormViewset(EndpointTester):
self.assertEqual(len(items), 2) self.assertEqual(len(items), 2)
self.assertEqual(items[0]['order'], f1.order + 1) self.assertEqual(items[0]['order'], f1.order + 1)
self.assertEqual(items[0]['definition_formal'], '[α∈X1, β∈X1] Pr1(F10[α,β])') self.assertEqual(items[0]['definition_formal'], '[α∈X1, β∈X1] Pr1(F10[α,β])')
class TestConstituentaAPI(EndpointTester):
''' Testing Constituenta view. '''
def setUp(self):
super().setUp()
self.rsform_owned = RSForm.create(title='Test', alias='T1', owner=self.user)
self.rsform_unowned = RSForm.create(title='Test2', alias='T2')
self.cst1 = Constituenta.objects.create(
alias='X1',
cst_type=CstType.BASE,
schema=self.rsform_owned.model,
order=1,
convention='Test',
term_raw='Test1',
term_resolved='Test1R',
term_forms=[{'text': 'form1', 'tags': 'sing,datv'}])
self.cst2 = Constituenta.objects.create(
alias='X2',
cst_type=CstType.BASE,
schema=self.rsform_unowned.model,
order=1,
convention='Test1',
term_raw='Test2',
term_resolved='Test2R'
)
self.cst3 = Constituenta.objects.create(
alias='X3',
schema=self.rsform_owned.model,
order=2,
term_raw='Test3',
term_resolved='Test3',
definition_raw='Test1',
definition_resolved='Test2'
)
self.invalid_cst = self.cst3.pk + 1337
@decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch')
def test_partial_update(self):
data = {'id': self.cst1.pk, 'convention': 'tt'}
self.executeForbidden(data=data, schema=self.rsform_unowned.model.pk)
self.logout()
self.executeForbidden(data=data, schema=self.rsform_owned.model.pk)
self.login()
response = self.executeOK(data=data, schema=self.rsform_owned.model.pk)
self.cst1.refresh_from_db()
self.assertEqual(response.data['convention'], 'tt')
self.assertEqual(self.cst1.convention, 'tt')
self.executeOK(data=data, schema=self.rsform_owned.model.pk)
@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'
}
response = self.executeOK(data=data, schema=self.rsform_owned.model.pk)
self.cst3.refresh_from_db()
self.assertEqual(response.data['term_resolved'], 'New term')
self.assertEqual(self.cst3.term_resolved, 'New term')
self.assertEqual(response.data['definition_resolved'], 'New def')
self.assertEqual(self.cst3.definition_resolved, 'New def')
@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}'
}
response = self.executeOK(data=data, schema=self.rsform_owned.model.pk)
self.cst3.refresh_from_db()
self.assertEqual(self.cst3.term_resolved, self.cst1.term_resolved)
self.assertEqual(response.data['term_resolved'], self.cst1.term_resolved)
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_readonly_cst_fields(self):
data = {
'id': self.cst1.pk,
'alias': 'X33',
'order': 10
}
response = self.executeOK(data=data, schema=self.rsform_owned.model.pk)
self.assertEqual(response.data['alias'], 'X1')
self.assertEqual(response.data['alias'], self.cst1.alias)
self.assertEqual(response.data['order'], self.cst1.order)
class TestInlineSynthesis(EndpointTester):
''' Testing Operations endpoints. '''
@decl_endpoint('/api/rsforms/inline-synthesis', method='patch')
def setUp(self):
super().setUp()
self.schema1 = RSForm.create(title='Test1', alias='T1', owner=self.user)
self.schema2 = RSForm.create(title='Test2', alias='T2', owner=self.user)
self.unowned = RSForm.create(title='Test3', alias='T3')
def test_inline_synthesis_inputs(self):
invalid_id = 1338
data = {
'receiver': self.unowned.model.pk,
'source': self.schema1.model.pk,
'items': [],
'substitutions': []
}
self.executeForbidden(data=data)
data['receiver'] = invalid_id
self.executeBadData(data=data)
data['receiver'] = self.schema1.model.pk
data['source'] = invalid_id
self.executeBadData(data=data)
data['source'] = self.schema1.model.pk
self.executeOK(data=data)
data['items'] = [invalid_id]
self.executeBadData(data=data)
def test_inline_synthesis(self):
ks1_x1 = self.schema1.insert_new('X1', term_raw='KS1X1') # -> delete
ks1_x2 = self.schema1.insert_new('X2', term_raw='KS1X2') # -> X2
ks1_s1 = self.schema1.insert_new('S1', definition_formal='X2', term_raw='KS1S1') # -> S1
ks1_d1 = self.schema1.insert_new('D1', definition_formal=r'S1\X1\X2') # -> D1
ks2_x1 = self.schema2.insert_new('X1', term_raw='KS2X1') # -> delete
ks2_x2 = self.schema2.insert_new('X2', term_raw='KS2X2') # -> X4
ks2_s1 = self.schema2.insert_new('S1', definition_formal='X2×X2', term_raw='KS2S1') # -> S2
ks2_d1 = self.schema2.insert_new('D1', definition_formal=r'S1\X1\X2') # -> D2
ks2_a1 = self.schema2.insert_new('A1', definition_formal='1=1') # -> not included in items
data = {
'receiver': self.schema1.model.pk,
'source': self.schema2.model.pk,
'items': [ks2_x1.pk, ks2_x2.pk, ks2_s1.pk, ks2_d1.pk],
'substitutions': [
{
'original': ks1_x1.pk,
'substitution': ks2_s1.pk
},
{
'original': ks2_x1.pk,
'substitution': ks1_s1.pk
}
]
}
response = self.executeOK(data=data)
result = {item['alias']: item for item in response.data['items']}
self.assertEqual(len(result), 6)
self.assertEqual(result['X2']['order'], 1)
self.assertEqual(result['X4']['order'], 2)
self.assertEqual(result['S1']['definition_formal'], 'X2')
self.assertEqual(result['S2']['definition_formal'], 'X4×X4')
self.assertEqual(result['D1']['definition_formal'], r'S1\S2\X2')
self.assertEqual(result['D2']['definition_formal'], r'S2\S1\X4')

View File

@ -9,11 +9,9 @@ library_router.register('rsforms', views.RSFormViewSet, 'RSForm')
urlpatterns = [ urlpatterns = [
path('constituents/<int:pk>', views.ConstituentAPIView.as_view(), name='constituenta-detail'),
path('rsforms/import-trs', views.TrsImportView.as_view()), path('rsforms/import-trs', views.TrsImportView.as_view()),
path('rsforms/create-detailed', views.create_rsform), path('rsforms/create-detailed', views.create_rsform),
path('rsforms/inline-synthesis', views.inline_synthesis),
path('operations/inline-synthesis', views.inline_synthesis),
path('rslang/parse-expression', views.parse_expression), path('rslang/parse-expression', views.parse_expression),
path('rslang/to-ascii', views.convert_to_ascii), path('rslang/to-ascii', views.convert_to_ascii),

View File

@ -1,6 +1,4 @@
''' REST API: Endpoint processors. ''' ''' REST API: Endpoint processors. '''
from .cctext import generate_lexeme, inflect, parse_text from .cctext import generate_lexeme, inflect, parse_text
from .constituents import ConstituentAPIView from .rsforms import RSFormViewSet, TrsImportView, create_rsform, inline_synthesis
from .operations import inline_synthesis
from .rsforms import RSFormViewSet, TrsImportView, create_rsform
from .rslang import convert_to_ascii, convert_to_math, parse_expression from .rslang import convert_to_ascii, convert_to_math, parse_expression

View File

@ -1,16 +0,0 @@
''' Endpoints for Constituenta. '''
from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework import generics
from shared import permissions
from .. import models as m
from .. import serializers as s
@extend_schema(tags=['Constituenta'])
@extend_schema_view()
class ConstituentAPIView(generics.RetrieveUpdateAPIView, permissions.EditorMixin):
''' Endpoint: Get / Update Constituenta. '''
queryset = m.Constituenta.objects.all()
serializer_class = s.CstSerializer

View File

@ -1,50 +0,0 @@
''' Endpoints for RSForm. '''
from typing import cast
from django.db import transaction
from drf_spectacular.utils import extend_schema
from rest_framework import status as c
from rest_framework.decorators import api_view
from rest_framework.request import Request
from rest_framework.response import Response
from .. import models as m
from .. import serializers as s
@extend_schema(
summary='Inline synthesis: merge one schema into another',
tags=['Operations'],
request=s.InlineSynthesisSerializer,
responses={c.HTTP_200_OK: s.RSFormParseSerializer}
)
@api_view(['PATCH'])
def inline_synthesis(request: Request):
''' Endpoint: Inline synthesis. '''
serializer = s.InlineSynthesisSerializer(
data=request.data,
context={'user': request.user}
)
serializer.is_valid(raise_exception=True)
receiver = m.RSForm(serializer.validated_data['receiver'])
items = cast(list[m.Constituenta], serializer.validated_data['items'])
with transaction.atomic():
new_items = receiver.insert_copy(items)
for substitution in serializer.validated_data['substitutions']:
original = cast(m.Constituenta, substitution['original'])
replacement = cast(m.Constituenta, substitution['substitution'])
if original in items:
index = next(i for (i, cst) in enumerate(items) if cst == original)
original = new_items[index]
else:
index = next(i for (i, cst) in enumerate(items) if cst == replacement)
replacement = new_items[index]
receiver.substitute(original, replacement, substitution['transfer_term'])
receiver.restore_order()
return Response(
status=c.HTTP_200_OK,
data=s.RSFormParseSerializer(receiver.model).data
)

View File

@ -12,6 +12,7 @@ from rest_framework import views, viewsets
from rest_framework.decorators import action, api_view from rest_framework.decorators import action, api_view
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ValidationError
from apps.library.models import AccessPolicy, LibraryItem, LibraryItemType, LocationHead from apps.library.models import AccessPolicy, LibraryItem, LibraryItemType, LocationHead
from apps.library.serializers import LibraryItemSerializer from apps.library.serializers import LibraryItemSerializer
@ -45,7 +46,8 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
'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 [
@ -88,15 +90,43 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
new_cst = m.RSForm(schema).create_cst(data, insert_after) new_cst = m.RSForm(schema).create_cst(data, insert_after)
schema.refresh_from_db() schema.refresh_from_db()
response = Response( return Response(
status=c.HTTP_201_CREATED, status=c.HTTP_201_CREATED,
data={ data={
'new_cst': s.CstSerializer(new_cst).data, 'new_cst': s.CstSerializer(new_cst).data,
'schema': s.RSFormParseSerializer(schema).data 'schema': s.RSFormParseSerializer(schema).data
} }
) )
response['Location'] = new_cst.get_absolute_url()
return response @extend_schema(
summary='update persistent attributes of a given constituenta',
tags=['RSForm'],
request=s.CstSerializer,
responses={
c.HTTP_200_OK: s.CstSerializer,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='update-cst')
def update_cst(self, request: Request, pk):
''' Update persistent attributes of a given constituenta. '''
schema = self._get_item()
serializer = s.CstSerializer(data=request.data, partial=True)
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)
})
serializer.update(instance=cst, validated_data=serializer.validated_data)
return Response(
status=c.HTTP_200_OK,
data=s.CstSerializer(cst).data
)
@extend_schema( @extend_schema(
summary='produce the structure of a given constituenta', summary='produce the structure of a given constituenta',
@ -174,7 +204,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
) )
@extend_schema( @extend_schema(
summary='substitute constituenta', summary='execute substitutions',
tags=['RSForm'], tags=['RSForm'],
request=s.CstSubstituteSerializer, request=s.CstSubstituteSerializer,
responses={ responses={
@ -198,7 +228,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
for substitution in serializer.validated_data['substitutions']: for substitution in serializer.validated_data['substitutions']:
original = cast(m.Constituenta, substitution['original']) original = cast(m.Constituenta, substitution['original'])
replacement = cast(m.Constituenta, substitution['substitution']) replacement = cast(m.Constituenta, substitution['substitution'])
m.RSForm(schema).substitute(original, replacement, substitution['transfer_term']) m.RSForm(schema).substitute(original, replacement)
schema.refresh_from_db() schema.refresh_from_db()
return Response( return Response(
@ -521,3 +551,41 @@ def _prepare_rsform_data(data: dict, request: Request, owner: Union[User, None])
data['access_policy'] = request.data.get('access_policy', AccessPolicy.PUBLIC) data['access_policy'] = request.data.get('access_policy', AccessPolicy.PUBLIC)
data['location'] = request.data.get('location', LocationHead.USER) data['location'] = request.data.get('location', LocationHead.USER)
@extend_schema(
summary='Inline synthesis: merge one schema into another',
tags=['Operations'],
request=s.InlineSynthesisSerializer,
responses={c.HTTP_200_OK: s.RSFormParseSerializer}
)
@api_view(['PATCH'])
def inline_synthesis(request: Request):
''' Endpoint: Inline synthesis. '''
serializer = s.InlineSynthesisSerializer(
data=request.data,
context={'user': request.user}
)
serializer.is_valid(raise_exception=True)
receiver = m.RSForm(serializer.validated_data['receiver'])
items = cast(list[m.Constituenta], serializer.validated_data['items'])
with transaction.atomic():
new_items = receiver.insert_copy(items)
for substitution in serializer.validated_data['substitutions']:
original = cast(m.Constituenta, substitution['original'])
replacement = cast(m.Constituenta, substitution['substitution'])
if original in items:
index = next(i for (i, cst) in enumerate(items) if cst == original)
original = new_items[index]
else:
index = next(i for (i, cst) in enumerate(items) if cst == replacement)
replacement = new_items[index]
receiver.substitute(original, replacement)
receiver.restore_order()
return Response(
status=c.HTTP_200_OK,
data=s.RSFormParseSerializer(receiver.model).data
)

View File

@ -2,19 +2,27 @@
# pylint: skip-file # pylint: skip-file
def constituentaNotOwned(title: str): def constituentaNotInRSform(title: str):
return f'Конституента не принадлежит схеме: {title}' return f'Конституента не принадлежит схеме: {title}'
def operationNotOwned(title: str): def constituentaNotFromOperation():
return f'Операция не принадлежит схеме: {title}' return f'Конституента не соответствую аргументам операции'
def operationNotInOSS(title: str):
return f'Операция не принадлежит ОСС: {title}'
def previousResultMissing():
return 'Отсутствует результат предыдущей операции'
def substitutionNotInList(): def substitutionNotInList():
return 'Отождествляемая конституента отсутствует в списке' return 'Отождествляемая конституента отсутствует в списке'
def schemaNotOwned(): def schemaForbidden():
return 'Нет доступа к схеме' return 'Нет доступа к схеме'
@ -22,6 +30,10 @@ def operationNotInput(title: str):
return f'Операция не является Загрузкой: {title}' return f'Операция не является Загрузкой: {title}'
def operationNotSynthesis(title: str):
return f'Операция не является Синтезом: {title}'
def operationResultNotEmpty(title: str): def operationResultNotEmpty(title: str):
return f'Результат операции не пуст: {title}' return f'Результат операции не пуст: {title}'

View File

@ -32,6 +32,24 @@ def _extract_item(obj: Any) -> LibraryItem:
}) })
def can_edit_item(user, obj: Any) -> bool:
if user.is_anonymous:
return False
if hasattr(user, 'is_staff') and user.is_staff:
return True
item = _extract_item(obj)
if item.owner == user:
return True
if Editor.objects.filter(
item=item,
editor=cast(User, user)
).exists() and item.access_policy != AccessPolicy.PRIVATE:
return True
return False
class GlobalAdmin(_Base): class GlobalAdmin(_Base):
''' Item permission: Admin or higher. ''' ''' Item permission: Admin or higher. '''

View File

@ -1,14 +0,0 @@
/**
* Endpoints: constituents.
*/
import { IConstituentaMeta, ICstUpdateData } from '@/models/rsform';
import { AxiosPatch, FrontExchange } from './apiTransport';
export function patchConstituenta(target: string, request: FrontExchange<ICstUpdateData, IConstituentaMeta>) {
AxiosPatch({
endpoint: `/api/constituents/${target}`,
request: request
});
}

View File

@ -1,14 +0,0 @@
/**
* Endpoints: operations.
*/
import { IInlineSynthesisData, IRSFormData } from '@/models/rsform';
import { AxiosPatch, FrontExchange } from './apiTransport';
export function patchInlineSynthesis(request: FrontExchange<IInlineSynthesisData, IRSFormData>) {
AxiosPatch({
endpoint: `/api/operations/inline-synthesis`,
request: request
});
}

View File

@ -7,6 +7,8 @@ import {
IOperationCreateData, IOperationCreateData,
IOperationCreatedResponse, IOperationCreatedResponse,
IOperationSchemaData, IOperationSchemaData,
IOperationSetInputData,
IOperationUpdateData,
IPositionsData, IPositionsData,
ITargetOperation ITargetOperation
} from '@/models/oss'; } from '@/models/oss';
@ -50,3 +52,24 @@ export function patchCreateInput(oss: string, request: FrontExchange<ITargetOper
request: request request: request
}); });
} }
export function patchSetInput(oss: string, request: FrontExchange<IOperationSetInputData, IOperationSchemaData>) {
AxiosPatch({
endpoint: `/api/oss/${oss}/set-input`,
request: request
});
}
export function patchUpdateOperation(oss: string, request: FrontExchange<IOperationUpdateData, IOperationSchemaData>) {
AxiosPatch({
endpoint: `/api/oss/${oss}/update-operation`,
request: request
});
}
export function postExecuteOperation(oss: string, request: FrontExchange<ITargetOperation, IOperationSchemaData>) {
AxiosPost({
endpoint: `/api/oss/${oss}/execute-operation`,
request: request
});
}

View File

@ -6,10 +6,13 @@ import { ILibraryCreateData, ILibraryItem } from '@/models/library';
import { ICstSubstituteData } from '@/models/oss'; import { ICstSubstituteData } from '@/models/oss';
import { import {
IConstituentaList, IConstituentaList,
IConstituentaMeta,
ICstCreateData, ICstCreateData,
ICstCreatedResponse, ICstCreatedResponse,
ICstMovetoData, ICstMovetoData,
ICstRenameData, ICstRenameData,
ICstUpdateData,
IInlineSynthesisData,
IProduceStructureResponse, IProduceStructureResponse,
IRSFormData, IRSFormData,
IRSFormUploadData, IRSFormUploadData,
@ -68,6 +71,13 @@ export function postCreateConstituenta(schema: string, request: FrontExchange<IC
}); });
} }
export function patchUpdateConstituenta(schema: string, request: FrontExchange<ICstUpdateData, IConstituentaMeta>) {
AxiosPatch({
endpoint: `/api/rsforms/${schema}/update-cst`,
request: request
});
}
export function patchDeleteConstituenta(schema: string, request: FrontExchange<IConstituentaList, IRSFormData>) { export function patchDeleteConstituenta(schema: string, request: FrontExchange<IConstituentaList, IRSFormData>) {
AxiosPatch({ AxiosPatch({
endpoint: `/api/rsforms/${schema}/delete-multiple-cst`, endpoint: `/api/rsforms/${schema}/delete-multiple-cst`,
@ -135,3 +145,10 @@ export function patchUploadTRS(target: string, request: FrontExchange<IRSFormUpl
} }
}); });
} }
export function patchInlineSynthesis(request: FrontExchange<IInlineSynthesisData, IRSFormData>) {
AxiosPatch({
endpoint: `/api/rsforms/inline-synthesis`,
request: request
});
}

View File

@ -89,8 +89,6 @@ export { BiHelpCircle as IconStatusUnknown } from 'react-icons/bi';
export { BiPauseCircle as IconStatusIncalculable } from 'react-icons/bi'; export { BiPauseCircle as IconStatusIncalculable } from 'react-icons/bi';
export { LuPower as IconKeepAliasOn } from 'react-icons/lu'; export { LuPower as IconKeepAliasOn } from 'react-icons/lu';
export { LuPowerOff as IconKeepAliasOff } from 'react-icons/lu'; export { LuPowerOff as IconKeepAliasOff } from 'react-icons/lu';
export { LuFlag as IconKeepTermOn } from 'react-icons/lu';
export { LuFlagOff as IconKeepTermOff } from 'react-icons/lu';
// ===== Domain actions ===== // ===== Domain actions =====
export { BiUpvote as IconMoveUp } from 'react-icons/bi'; export { BiUpvote as IconMoveUp } from 'react-icons/bi';
@ -108,7 +106,7 @@ export { LuNetwork as IconGenerateStructure } from 'react-icons/lu';
export { LuBookCopy as IconInlineSynthesis } from 'react-icons/lu'; export { LuBookCopy as IconInlineSynthesis } from 'react-icons/lu';
export { LuWand2 as IconGenerateNames } from 'react-icons/lu'; export { LuWand2 as IconGenerateNames } from 'react-icons/lu';
export { GrConnect as IconConnect } from 'react-icons/gr'; export { GrConnect as IconConnect } from 'react-icons/gr';
export { BsPlay as IconExecute } from 'react-icons/bs'; export { BiPlayCircle as IconExecute } from 'react-icons/bi';
// ======== Graph UI ======= // ======== Graph UI =======
export { BiCollapse as IconGraphCollapse } from 'react-icons/bi'; export { BiCollapse as IconGraphCollapse } from 'react-icons/bi';

View File

@ -1,105 +1,126 @@
'use client'; 'use client';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import BadgeConstituenta from '@/components/info/BadgeConstituenta'; import BadgeConstituenta from '@/components/info/BadgeConstituenta';
import SelectConstituenta from '@/components/select/SelectConstituenta'; import SelectConstituenta from '@/components/select/SelectConstituenta';
import DataTable, { createColumnHelper } from '@/components/ui/DataTable'; import DataTable, { createColumnHelper } from '@/components/ui/DataTable';
import Label from '@/components/ui/Label';
import MiniButton from '@/components/ui/MiniButton'; import MiniButton from '@/components/ui/MiniButton';
import { useConceptOptions } from '@/context/ConceptOptionsContext'; import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { IConstituenta, IRSForm, ISingleSubstitution } from '@/models/rsform'; import { ILibraryItem } from '@/models/library';
import { describeConstituenta } from '@/utils/labels'; import { ICstSubstitute, IMultiSubstitution } from '@/models/oss';
import { ConstituentaID, IConstituenta, IRSForm } from '@/models/rsform';
import { errors } from '@/utils/labels';
import { import { IconPageLeft, IconPageRight, IconRemove, IconReplace } from '../Icons';
IconKeepAliasOff,
IconKeepAliasOn,
IconKeepTermOff,
IconKeepTermOn,
IconPageFirst,
IconPageLast,
IconPageLeft,
IconPageRight,
IconRemove,
IconReplace
} from '../Icons';
import NoData from '../ui/NoData'; import NoData from '../ui/NoData';
import SelectLibraryItem from './SelectLibraryItem';
interface PickSubstitutionsProps { interface PickSubstitutionsProps {
substitutions: ICstSubstitute[];
setSubstitutions: React.Dispatch<React.SetStateAction<ICstSubstitute[]>>;
prefixID: string; prefixID: string;
rows?: number; rows?: number;
allowSelfSubstitution?: boolean;
schema1?: IRSForm; schemas: IRSForm[];
schema2?: IRSForm; filter?: (cst: IConstituenta) => boolean;
filter1?: (cst: IConstituenta) => boolean;
filter2?: (cst: IConstituenta) => boolean;
items: ISingleSubstitution[];
setItems: React.Dispatch<React.SetStateAction<ISingleSubstitution[]>>;
} }
function SubstitutionIcon({ item }: { item: ISingleSubstitution }) { const columnHelper = createColumnHelper<IMultiSubstitution>();
if (item.deleteRight) {
if (item.takeLeftTerm) {
return <IconPageRight size='1.2rem' />;
} else {
return <IconPageLast size='1.2rem' />;
}
} else {
if (item.takeLeftTerm) {
return <IconPageFirst size='1.2rem' />;
} else {
return <IconPageLeft size='1.2rem' />;
}
}
}
const columnHelper = createColumnHelper<ISingleSubstitution>();
function PickSubstitutions({ function PickSubstitutions({
items, substitutions,
schema1, setSubstitutions,
schema2, prefixID,
filter1,
filter2,
rows, rows,
setItems, schemas,
prefixID filter,
allowSelfSubstitution
}: PickSubstitutionsProps) { }: PickSubstitutionsProps) {
const { colors } = useConceptOptions(); const { colors } = useConceptOptions();
const [leftArgument, setLeftArgument] = useState<ILibraryItem | undefined>(
schemas.length === 1 ? schemas[0] : undefined
);
const [rightArgument, setRightArgument] = useState<ILibraryItem | undefined>(
schemas.length === 1 && allowSelfSubstitution ? schemas[0] : undefined
);
const [leftCst, setLeftCst] = useState<IConstituenta | undefined>(undefined); const [leftCst, setLeftCst] = useState<IConstituenta | undefined>(undefined);
const [rightCst, setRightCst] = useState<IConstituenta | undefined>(undefined); const [rightCst, setRightCst] = useState<IConstituenta | undefined>(undefined);
const [deleteRight, setDeleteRight] = useState(true);
const [takeLeftTerm, setTakeLeftTerm] = useState(true);
const [deleteRight, setDeleteRight] = useState(true);
const toggleDelete = () => setDeleteRight(prev => !prev); const toggleDelete = () => setDeleteRight(prev => !prev);
const toggleTerm = () => setTakeLeftTerm(prev => !prev);
const getSchemaByCst = useCallback(
(id: ConstituentaID): IRSForm | undefined => {
for (const schema of schemas) {
const cst = schema.cstByID.get(id);
if (cst) {
return schema;
}
}
return undefined;
},
[schemas]
);
const getConstituenta = useCallback(
(id: ConstituentaID): IConstituenta | undefined => {
for (const schema of schemas) {
const cst = schema.cstByID.get(id);
if (cst) {
return cst;
}
}
return undefined;
},
[schemas]
);
const substitutionData: IMultiSubstitution[] = useMemo(
() =>
substitutions.map(item => ({
original_source: getSchemaByCst(item.original),
original: getConstituenta(item.original),
substitution: getConstituenta(item.substitution),
substitution_source: getSchemaByCst(item.substitution)
})),
[getConstituenta, getSchemaByCst, substitutions]
);
function addSubstitution() { function addSubstitution() {
if (!leftCst || !rightCst) { if (!leftCst || !rightCst) {
return; return;
} }
const newSubstitution: ISingleSubstitution = { const newSubstitution: ICstSubstitute = {
leftCst: leftCst, original: deleteRight ? rightCst.id : leftCst.id,
rightCst: rightCst, substitution: deleteRight ? leftCst.id : rightCst.id
deleteRight: deleteRight,
takeLeftTerm: takeLeftTerm
}; };
setItems([ const toDelete = substitutions.map(item => item.original);
newSubstitution, const replacements = substitutions.map(item => item.substitution);
...items.filter( console.log(toDelete, replacements);
item => console.log(newSubstitution);
(!item.deleteRight && item.leftCst.id !== leftCst.id) || if (
(item.deleteRight && item.rightCst.id !== rightCst.id) toDelete.includes(newSubstitution.original) ||
) toDelete.includes(newSubstitution.substitution) ||
]); replacements.includes(newSubstitution.original)
) {
toast.error(errors.reuseOriginal);
return;
}
setSubstitutions(prev => [...prev, newSubstitution]);
setLeftCst(undefined);
setRightCst(undefined);
} }
const handleDeleteRow = useCallback( const handleDeleteRow = useCallback(
(row: number) => { (row: number) => {
setItems(prev => { setSubstitutions(prev => {
const newItems: ISingleSubstitution[] = []; const newItems: ICstSubstitute[] = [];
prev.forEach((item, index) => { prev.forEach((item, index) => {
if (index !== row) { if (index !== row) {
newItems.push(item); newItems.push(item);
@ -108,54 +129,62 @@ function PickSubstitutions({
return newItems; return newItems;
}); });
}, },
[setItems] [setSubstitutions]
); );
const columns = useMemo( const columns = useMemo(
() => [ () => [
columnHelper.accessor(item => describeConstituenta(item.leftCst), { columnHelper.accessor(item => item.substitution_source?.alias ?? 'N/A', {
id: 'left_text', id: 'left_schema',
header: 'Описание', header: 'Операция',
size: 1000, size: 100,
cell: props => <div className='text-xs text-ellipsis'>{props.getValue()}</div> cell: props => <div className='min-w-[10.5rem] text-ellipsis text-right'>{props.getValue()}</div>
}), }),
columnHelper.accessor(item => item.leftCst.alias, { columnHelper.accessor(item => item.substitution?.alias ?? 'N/A', {
id: 'left_alias', id: 'left_alias',
header: () => <span className='pl-3'>Имя</span>, header: () => <span className='pl-3'>Имя</span>,
size: 65, size: 65,
cell: props => ( cell: props =>
<BadgeConstituenta theme={colors} value={props.row.original.leftCst} prefixID={`${prefixID}_1_`} /> props.row.original.substitution ? (
) <BadgeConstituenta theme={colors} value={props.row.original.substitution} prefixID={`${prefixID}_1_`} />
) : (
'N/A'
)
}), }),
columnHelper.display({ columnHelper.display({
id: 'status', id: 'status',
header: '', header: '',
size: 40, size: 40,
cell: props => <SubstitutionIcon item={props.row.original} /> cell: () => <IconPageRight size='1.2rem' />
}), }),
columnHelper.accessor(item => item.rightCst.alias, { columnHelper.accessor(item => item.original?.alias ?? 'N/A', {
id: 'right_alias', id: 'right_alias',
header: () => <span className='pl-3'>Имя</span>, header: () => <span className='pl-3'>Имя</span>,
size: 65, size: 65,
cell: props => ( cell: props =>
<BadgeConstituenta theme={colors} value={props.row.original.rightCst} prefixID={`${prefixID}_2_`} /> props.row.original.original ? (
) <BadgeConstituenta theme={colors} value={props.row.original.original} prefixID={`${prefixID}_1_`} />
) : (
'N/A'
)
}), }),
columnHelper.accessor(item => describeConstituenta(item.rightCst), { columnHelper.accessor(item => item.original_source?.alias ?? 'N/A', {
id: 'right_text', id: 'right_schema',
header: 'Описание', header: 'Операция',
minSize: 1000, size: 100,
cell: props => <div className='text-xs text-ellipsis text-pretty'>{props.getValue()}</div> cell: props => <div className='min-w-[8rem] text-ellipsis'>{props.getValue()}</div>
}), }),
columnHelper.display({ columnHelper.display({
id: 'actions', id: 'actions',
cell: props => ( cell: props => (
<MiniButton <div className='max-w-fit'>
noHover <MiniButton
title='Удалить' noHover
icon={<IconRemove size='1rem' className='icon-red' />} title='Удалить'
onClick={() => handleDeleteRow(props.row.index)} icon={<IconRemove size='1rem' className='icon-red' />}
/> onClick={() => handleDeleteRow(props.row.index)}
/>
</div>
) )
}) })
], ],
@ -165,87 +194,65 @@ function PickSubstitutions({
return ( return (
<div className='flex flex-col w-full'> <div className='flex flex-col w-full'>
<div className='flex items-end gap-3 justify-stretch'> <div className='flex items-end gap-3 justify-stretch'>
<div className='flex-grow basis-1/2'> <div className='flex-grow flex flex-col basis-1/2'>
<div className='flex items-center justify-between'> <div className='flex flex-col gap-[0.125rem] border-x border-t clr-input'>
<Label text={schema1 !== schema2 ? schema1?.alias ?? 'Схема 1' : ''} /> <SelectLibraryItem
<div className='cc-icons'> noBorder
<MiniButton placeholder='Выберите аргумент'
title='Сохранить конституенту' items={allowSelfSubstitution ? schemas : schemas.filter(item => item.id !== rightArgument?.id)}
noHover value={leftArgument}
onClick={toggleDelete} onSelectValue={setLeftArgument}
icon={ />
deleteRight ? ( <SelectConstituenta
<IconKeepAliasOn size='1rem' className='clr-text-green' /> noBorder
) : ( items={(leftArgument as IRSForm)?.items.filter(
<IconKeepAliasOff size='1rem' className='clr-text-red' /> cst => !substitutions.find(item => item.original === cst.id) && (!filter || filter(cst))
) )}
} value={leftCst}
/> onSelectValue={setLeftCst}
<MiniButton />
title='Сохранить термин'
noHover
onClick={toggleTerm}
icon={
takeLeftTerm ? (
<IconKeepTermOn size='1rem' className='clr-text-green' />
) : (
<IconKeepTermOff size='1rem' className='clr-text-red' />
)
}
/>
</div>
</div> </div>
<SelectConstituenta </div>
items={schema1?.items.filter(cst => !filter1 || filter1(cst))} <div className='flex flex-col gap-1'>
value={leftCst} <MiniButton
onSelectValue={setLeftCst} title={deleteRight ? 'Заменить правую' : 'Заменить левую'}
onClick={toggleDelete}
icon={
deleteRight ? (
<IconPageRight size='1.5rem' className='clr-text-primary' />
) : (
<IconPageLeft size='1.5rem' className='clr-text-primary' />
)
}
/>
<MiniButton
title='Добавить в таблицу отождествлений'
className='mb-[0.375rem] grow-0'
icon={<IconReplace size='1.5rem' className='icon-primary' />}
disabled={!leftCst || !rightCst || leftCst === rightCst}
onClick={addSubstitution}
/> />
</div> </div>
<MiniButton
noHover
title='Добавить в таблицу отождествлений'
className='mb-[0.375rem] grow-0'
icon={<IconReplace size='1.5rem' className='icon-primary' />}
disabled={!leftCst || !rightCst || leftCst === rightCst}
onClick={addSubstitution}
/>
<div className='flex-grow basis-1/2'> <div className='flex-grow basis-1/2'>
<div className='flex items-center justify-between'> <div className='flex flex-col gap-[0.125rem] border-x border-t clr-input'>
<Label text={schema1 !== schema2 ? schema2?.alias ?? 'Схема 2' : ''} /> <SelectLibraryItem
<div className='cc-icons'> noBorder
<MiniButton placeholder='Выберите аргумент'
title='Сохранить конституенту' items={allowSelfSubstitution ? schemas : schemas.filter(item => item.id !== leftArgument?.id)}
noHover value={rightArgument}
onClick={toggleDelete} onSelectValue={setRightArgument}
icon={ />
!deleteRight ? ( <SelectConstituenta
<IconKeepAliasOn size='1rem' className='clr-text-green' /> noBorder
) : ( items={(rightArgument as IRSForm)?.items.filter(
<IconKeepAliasOff size='1rem' className='clr-text-red' /> cst => !substitutions.find(item => item.original === cst.id) && (!filter || filter(cst))
) )}
} value={rightCst}
/> onSelectValue={setRightCst}
<MiniButton />
title='Сохранить термин'
noHover
onClick={toggleTerm}
icon={
!takeLeftTerm ? (
<IconKeepTermOn size='1rem' className='clr-text-green' />
) : (
<IconKeepTermOff size='1rem' className='clr-text-red' />
)
}
/>
</div>
</div> </div>
<SelectConstituenta
items={schema2?.items.filter(cst => !filter2 || filter2(cst))}
value={rightCst}
onSelectValue={setRightCst}
/>
</div> </div>
</div> </div>
@ -256,7 +263,7 @@ function PickSubstitutions({
className='w-full text-sm border select-none cc-scroll-y' className='w-full text-sm border select-none cc-scroll-y'
rows={rows} rows={rows}
contentHeight='1.3rem' contentHeight='1.3rem'
data={items} data={substitutionData}
columns={columns} columns={columns}
headPosition='0' headPosition='0'
noDataComponent={ noDataComponent={

View File

@ -49,7 +49,7 @@ function SelectConstituenta({
<SelectSingle <SelectSingle
className={clsx('text-ellipsis', className)} className={clsx('text-ellipsis', className)}
options={options} options={options}
value={value ? { value: value.id, label: `${value.alias}: ${describeConstituentaTerm(value)}` } : undefined} value={value ? { value: value.id, label: `${value.alias}: ${describeConstituentaTerm(value)}` } : null}
onChange={data => onSelectValue(items?.find(cst => cst.id === data?.value))} onChange={data => onSelectValue(items?.find(cst => cst.id === data?.value))}
// @ts-expect-error: TODO: use type definitions from react-select in filter object // @ts-expect-error: TODO: use type definitions from react-select in filter object
filterOption={filter} filterOption={filter}

View File

@ -0,0 +1,60 @@
'use client';
import clsx from 'clsx';
import { useCallback, useMemo } from 'react';
import { ILibraryItem, LibraryItemID } from '@/models/library';
import { matchLibraryItem } from '@/models/libraryAPI';
import { CProps } from '../props';
import SelectSingle from '../ui/SelectSingle';
interface SelectLibraryItemProps extends CProps.Styling {
items?: ILibraryItem[];
value?: ILibraryItem;
onSelectValue: (newValue?: ILibraryItem) => void;
placeholder?: string;
noBorder?: boolean;
}
function SelectLibraryItem({
className,
items,
value,
onSelectValue,
placeholder = 'Выберите схему',
...restProps
}: SelectLibraryItemProps) {
const options = useMemo(() => {
return (
items?.map(cst => ({
value: cst.id,
label: `${cst.alias}: ${cst.title}`
})) ?? []
);
}, [items]);
const filter = useCallback(
(option: { value: LibraryItemID | undefined; label: string }, inputValue: string) => {
const item = items?.find(item => item.id === option.value);
return !item ? false : matchLibraryItem(item, inputValue);
},
[items]
);
return (
<SelectSingle
className={clsx('text-ellipsis', className)}
options={options}
value={value ? { value: value.id, label: `${value.alias}: ${value.title}` } : null}
onChange={data => onSelectValue(items?.find(cst => cst.id === data?.value))}
// @ts-expect-error: TODO: use type definitions from react-select in filter object
filterOption={filter}
placeholder={placeholder}
{...restProps}
/>
);
}
export default SelectLibraryItem;

View File

@ -47,7 +47,7 @@ function SelectOperation({
<SelectSingle <SelectSingle
className={clsx('text-ellipsis', className)} className={clsx('text-ellipsis', className)}
options={options} options={options}
value={value ? { value: value.id, label: `${value.alias}: ${value.title}` } : undefined} value={value ? { value: value.id, label: `${value.alias}: ${value.title}` } : null}
onChange={data => onSelectValue(items?.find(cst => cst.id === data?.value))} onChange={data => onSelectValue(items?.find(cst => cst.id === data?.value))}
// @ts-expect-error: TODO: use type definitions from react-select in filter object // @ts-expect-error: TODO: use type definitions from react-select in filter object
filterOption={filter} filterOption={filter}

View File

@ -49,7 +49,7 @@ function SelectUser({
<SelectSingle <SelectSingle
className={clsx('text-ellipsis', className)} className={clsx('text-ellipsis', className)}
options={options} options={options}
value={value ? { value: value, label: getUserLabel(value) } : undefined} value={value ? { value: value, label: getUserLabel(value) } : null}
onChange={data => { onChange={data => {
if (data !== null && data.value !== undefined) onSelectValue(data.value); if (data !== null && data.value !== undefined) onSelectValue(data.value);
}} }}

View File

@ -12,7 +12,15 @@ import {
patchSetOwner, patchSetOwner,
postSubscribe postSubscribe
} from '@/backend/library'; } from '@/backend/library';
import { patchCreateInput, patchDeleteOperation, patchUpdatePositions, postCreateOperation } from '@/backend/oss'; import {
patchCreateInput,
patchDeleteOperation,
patchSetInput,
patchUpdateOperation,
patchUpdatePositions,
postCreateOperation,
postExecuteOperation
} from '@/backend/oss';
import { type ErrorData } from '@/components/info/InfoError'; import { type ErrorData } from '@/components/info/InfoError';
import { AccessPolicy, ILibraryItem } from '@/models/library'; import { AccessPolicy, ILibraryItem } from '@/models/library';
import { ILibraryUpdateData } from '@/models/library'; import { ILibraryUpdateData } from '@/models/library';
@ -21,6 +29,8 @@ import {
IOperationCreateData, IOperationCreateData,
IOperationSchema, IOperationSchema,
IOperationSchemaData, IOperationSchemaData,
IOperationSetInputData,
IOperationUpdateData,
IPositionsData, IPositionsData,
ITargetOperation ITargetOperation
} from '@/models/oss'; } from '@/models/oss';
@ -55,6 +65,9 @@ interface IOssContext {
createOperation: (data: IOperationCreateData, callback?: DataCallback<IOperation>) => void; createOperation: (data: IOperationCreateData, callback?: DataCallback<IOperation>) => void;
deleteOperation: (data: ITargetOperation, callback?: () => void) => void; deleteOperation: (data: ITargetOperation, callback?: () => void) => void;
createInput: (data: ITargetOperation, callback?: DataCallback<ILibraryItem>) => void; createInput: (data: ITargetOperation, callback?: DataCallback<ILibraryItem>) => void;
setInput: (data: IOperationSetInputData, callback?: () => void) => void;
updateOperation: (data: IOperationUpdateData, callback?: () => void) => void;
executeOperation: (data: ITargetOperation, callback?: () => void) => void;
} }
const OssContext = createContext<IOssContext | null>(null); const OssContext = createContext<IOssContext | null>(null);
@ -333,6 +346,71 @@ export const OssState = ({ itemID, children }: OssStateProps) => {
[itemID, library] [itemID, library]
); );
const setInput = useCallback(
(data: IOperationSetInputData, callback?: () => void) => {
if (!schema) {
return;
}
setProcessingError(undefined);
patchSetInput(itemID, {
data: data,
showError: true,
setLoading: setProcessing,
onError: setProcessingError,
onSuccess: newData => {
library.setGlobalOSS(newData);
library.localUpdateTimestamp(newData.id);
if (callback) callback();
}
});
},
[itemID, schema, library]
);
const updateOperation = useCallback(
(data: IOperationUpdateData, callback?: () => void) => {
if (!schema) {
return;
}
setProcessingError(undefined);
patchUpdateOperation(itemID, {
data: data,
showError: true,
setLoading: setProcessing,
onError: setProcessingError,
onSuccess: newData => {
library.setGlobalOSS(newData);
library.reloadItems(() => {
if (callback) callback();
});
}
});
},
[itemID, schema, library]
);
const executeOperation = useCallback(
(data: ITargetOperation, callback?: () => void) => {
if (!schema) {
return;
}
setProcessingError(undefined);
postExecuteOperation(itemID, {
data: data,
showError: true,
setLoading: setProcessing,
onError: setProcessingError,
onSuccess: newData => {
library.setGlobalOSS(newData);
library.reloadItems(() => {
if (callback) callback();
});
}
});
},
[itemID, schema, library]
);
return ( return (
<OssContext.Provider <OssContext.Provider
value={{ value={{
@ -356,7 +434,10 @@ export const OssState = ({ itemID, children }: OssStateProps) => {
savePositions, savePositions,
createOperation, createOperation,
deleteOperation, deleteOperation,
createInput createInput,
setInput,
updateOperation,
executeOperation
}} }}
> >
{children} {children}

View File

@ -3,7 +3,6 @@
import { createContext, useCallback, useContext, useMemo, useState } from 'react'; import { createContext, useCallback, useContext, useMemo, useState } from 'react';
import { DataCallback } from '@/backend/apiTransport'; import { DataCallback } from '@/backend/apiTransport';
import { patchConstituenta } from '@/backend/constituents';
import { import {
deleteUnsubscribe, deleteUnsubscribe,
patchLibraryItem, patchLibraryItem,
@ -14,16 +13,17 @@ import {
postCreateVersion, postCreateVersion,
postSubscribe postSubscribe
} from '@/backend/library'; } from '@/backend/library';
import { patchInlineSynthesis } from '@/backend/operations';
import { import {
getTRSFile, getTRSFile,
patchDeleteConstituenta, patchDeleteConstituenta,
patchInlineSynthesis,
patchMoveConstituenta, patchMoveConstituenta,
patchProduceStructure, patchProduceStructure,
patchRenameConstituenta, patchRenameConstituenta,
patchResetAliases, patchResetAliases,
patchRestoreOrder, patchRestoreOrder,
patchSubstituteConstituents, patchSubstituteConstituents,
patchUpdateConstituenta,
patchUploadTRS, patchUploadTRS,
postCreateConstituenta postCreateConstituenta
} from '@/backend/rsforms'; } from '@/backend/rsforms';
@ -157,7 +157,11 @@ export const RSFormState = ({ itemID, versionID, children }: RSFormStateProps) =
onSuccess: newData => { onSuccess: newData => {
setSchema(Object.assign(schema, newData)); setSchema(Object.assign(schema, newData));
library.localUpdateItem(newData); library.localUpdateItem(newData);
if (callback) callback(newData); if (library.globalOSS?.schemas.includes(newData.id)) {
library.reloadOSS(() => {
if (callback) callback(newData);
});
} else if (callback) callback(newData);
} }
}); });
}, },
@ -435,7 +439,7 @@ export const RSFormState = ({ itemID, versionID, children }: RSFormStateProps) =
const cstUpdate = useCallback( const cstUpdate = useCallback(
(data: ICstUpdateData, callback?: DataCallback<IConstituentaMeta>) => { (data: ICstUpdateData, callback?: DataCallback<IConstituentaMeta>) => {
setProcessingError(undefined); setProcessingError(undefined);
patchConstituenta(String(data.id), { patchUpdateConstituenta(itemID, {
data: data, data: data,
showError: true, showError: true,
setLoading: setProcessing, setLoading: setProcessing,

View File

@ -0,0 +1,71 @@
'use client';
import clsx from 'clsx';
import { useCallback, useMemo, useState } from 'react';
import { IconReset } from '@/components/Icons';
import PickSchema from '@/components/select/PickSchema';
import Label from '@/components/ui/Label';
import MiniButton from '@/components/ui/MiniButton';
import Modal, { ModalProps } from '@/components/ui/Modal';
import { ILibraryItem, LibraryItemID } from '@/models/library';
import { IOperation, IOperationSchema } from '@/models/oss';
interface DlgChangeInputSchemaProps extends Pick<ModalProps, 'hideWindow'> {
oss: IOperationSchema;
target: IOperation;
onSubmit: (newSchema: LibraryItemID | undefined) => void;
}
function DlgChangeInputSchema({ oss, hideWindow, target, onSubmit }: DlgChangeInputSchemaProps) {
const [selected, setSelected] = useState<LibraryItemID | undefined>(target.result ?? undefined);
const baseFilter = useCallback(
(item: ILibraryItem) => !oss.schemas.includes(item.id) || item.id === selected || item.id === target.result,
[oss, selected, target]
);
const isValid = useMemo(() => target.result !== selected, [target, selected]);
const handleSelectLocation = useCallback((newValue: LibraryItemID) => {
setSelected(newValue);
}, []);
function handleSubmit() {
onSubmit(selected);
}
return (
<Modal
overflowVisible
header='Выбор концептуальной схемы'
submitText='Подтвердить выбор'
hideWindow={hideWindow}
canSubmit={isValid}
onSubmit={handleSubmit}
className={clsx('w-[35rem]', 'pb-3 px-6 cc-column')}
>
<div className='flex justify-between gap-3 items-center'>
<div className='flex gap-3'>
<Label text='Загружаемая концептуальная схема' />
<MiniButton
title='Сбросить выбор схемы'
noHover
noPadding
icon={<IconReset size='1.25rem' className='icon-primary' />}
onClick={() => setSelected(undefined)}
disabled={selected == undefined}
/>
</div>
</div>
<PickSchema
value={selected} // prettier: split-line
onSelectValue={handleSelectLocation}
rows={8}
baseFilter={baseFilter}
/>
</Modal>
);
}
export default DlgChangeInputSchema;

View File

@ -10,8 +10,8 @@ import Overlay from '@/components/ui/Overlay';
import TabLabel from '@/components/ui/TabLabel'; import TabLabel from '@/components/ui/TabLabel';
import { useLibrary } from '@/context/LibraryContext'; import { useLibrary } from '@/context/LibraryContext';
import { LibraryItemID } from '@/models/library'; import { LibraryItemID } from '@/models/library';
import { HelpTopic, Position2D } from '@/models/miscellaneous'; import { HelpTopic } from '@/models/miscellaneous';
import { IOperationCreateData, IOperationPosition, IOperationSchema, OperationID, OperationType } from '@/models/oss'; import { IOperationCreateData, IOperationSchema, OperationID, OperationType } from '@/models/oss';
import { PARAMETER } from '@/utils/constants'; import { PARAMETER } from '@/utils/constants';
import { describeOperationType, labelOperationType } from '@/utils/labels'; import { describeOperationType, labelOperationType } from '@/utils/labels';
@ -21,8 +21,6 @@ import TabSynthesisOperation from './TabSynthesisOperation';
interface DlgCreateOperationProps { interface DlgCreateOperationProps {
hideWindow: () => void; hideWindow: () => void;
oss: IOperationSchema; oss: IOperationSchema;
positions: IOperationPosition[];
insertPosition: Position2D;
onCreate: (data: IOperationCreateData) => void; onCreate: (data: IOperationCreateData) => void;
} }
@ -31,7 +29,7 @@ export enum TabID {
SYNTHESIS = 1 SYNTHESIS = 1
} }
function DlgCreateOperation({ hideWindow, oss, insertPosition, positions, onCreate }: DlgCreateOperationProps) { function DlgCreateOperation({ hideWindow, oss, onCreate }: DlgCreateOperationProps) {
const library = useLibrary(); const library = useLibrary();
const [activeTab, setActiveTab] = useState(TabID.INPUT); const [activeTab, setActiveTab] = useState(TabID.INPUT);
@ -40,7 +38,6 @@ function DlgCreateOperation({ hideWindow, oss, insertPosition, positions, onCrea
const [comment, setComment] = useState(''); const [comment, setComment] = useState('');
const [inputs, setInputs] = useState<OperationID[]>([]); const [inputs, setInputs] = useState<OperationID[]>([]);
const [attachedID, setAttachedID] = useState<LibraryItemID | undefined>(undefined); const [attachedID, setAttachedID] = useState<LibraryItemID | undefined>(undefined);
const [syncText, setSyncText] = useState(true);
const [createSchema, setCreateSchema] = useState(false); const [createSchema, setCreateSchema] = useState(false);
const isValid = useMemo( const isValid = useMemo(
@ -62,16 +59,15 @@ function DlgCreateOperation({ hideWindow, oss, insertPosition, positions, onCrea
const handleSubmit = () => { const handleSubmit = () => {
const data: IOperationCreateData = { const data: IOperationCreateData = {
item_data: { item_data: {
position_x: insertPosition.x, position_x: 0,
position_y: insertPosition.y, position_y: 0,
alias: alias, alias: alias,
title: title, title: title,
comment: comment, comment: comment,
sync_text: activeTab === TabID.INPUT ? syncText : true,
operation_type: activeTab === TabID.INPUT ? OperationType.INPUT : OperationType.SYNTHESIS, operation_type: activeTab === TabID.INPUT ? OperationType.INPUT : OperationType.SYNTHESIS,
result: activeTab === TabID.INPUT ? attachedID ?? null : null result: activeTab === TabID.INPUT ? attachedID ?? null : null
}, },
positions: positions, positions: [],
arguments: activeTab === TabID.INPUT ? undefined : inputs.length > 0 ? inputs : undefined, arguments: activeTab === TabID.INPUT ? undefined : inputs.length > 0 ? inputs : undefined,
create_schema: createSchema create_schema: createSchema
}; };
@ -91,14 +87,12 @@ function DlgCreateOperation({ hideWindow, oss, insertPosition, positions, onCrea
setTitle={setTitle} setTitle={setTitle}
attachedID={attachedID} attachedID={attachedID}
setAttachedID={setAttachedID} setAttachedID={setAttachedID}
syncText={syncText}
setSyncText={setSyncText}
createSchema={createSchema} createSchema={createSchema}
setCreateSchema={setCreateSchema} setCreateSchema={setCreateSchema}
/> />
</TabPanel> </TabPanel>
), ),
[alias, comment, title, attachedID, syncText, oss, createSchema] [alias, comment, title, attachedID, oss, createSchema]
); );
const synthesisPanel = useMemo( const synthesisPanel = useMemo(

View File

@ -5,7 +5,6 @@ import { useCallback, useEffect } from 'react';
import { IconReset } from '@/components/Icons'; import { IconReset } from '@/components/Icons';
import PickSchema from '@/components/select/PickSchema'; import PickSchema from '@/components/select/PickSchema';
import Checkbox from '@/components/ui/Checkbox'; import Checkbox from '@/components/ui/Checkbox';
import FlexColumn from '@/components/ui/FlexColumn';
import Label from '@/components/ui/Label'; import Label from '@/components/ui/Label';
import MiniButton from '@/components/ui/MiniButton'; import MiniButton from '@/components/ui/MiniButton';
import TextArea from '@/components/ui/TextArea'; import TextArea from '@/components/ui/TextArea';
@ -25,8 +24,6 @@ interface TabInputOperationProps {
setComment: React.Dispatch<React.SetStateAction<string>>; setComment: React.Dispatch<React.SetStateAction<string>>;
attachedID: LibraryItemID | undefined; attachedID: LibraryItemID | undefined;
setAttachedID: React.Dispatch<React.SetStateAction<LibraryItemID | undefined>>; setAttachedID: React.Dispatch<React.SetStateAction<LibraryItemID | undefined>>;
syncText: boolean;
setSyncText: React.Dispatch<React.SetStateAction<boolean>>;
createSchema: boolean; createSchema: boolean;
setCreateSchema: React.Dispatch<React.SetStateAction<boolean>>; setCreateSchema: React.Dispatch<React.SetStateAction<boolean>>;
} }
@ -41,8 +38,6 @@ function TabInputOperation({
setComment, setComment,
attachedID, attachedID,
setAttachedID, setAttachedID,
syncText,
setSyncText,
createSchema, createSchema,
setCreateSchema setCreateSchema
}: TabInputOperationProps) { }: TabInputOperationProps) {
@ -51,9 +46,8 @@ function TabInputOperation({
useEffect(() => { useEffect(() => {
if (createSchema) { if (createSchema) {
setAttachedID(undefined); setAttachedID(undefined);
setSyncText(true);
} }
}, [createSchema, setAttachedID, setSyncText]); }, [createSchema, setAttachedID]);
return ( return (
<AnimateFade className='cc-column'> <AnimateFade className='cc-column'>
@ -62,27 +56,19 @@ function TabInputOperation({
label='Полное название' label='Полное название'
value={title} value={title}
onChange={event => setTitle(event.target.value)} onChange={event => setTitle(event.target.value)}
disabled={syncText && attachedID !== undefined} disabled={attachedID !== undefined}
/> />
<div className='flex gap-6'> <div className='flex gap-6'>
<FlexColumn> <TextInput
<TextInput id='operation_alias'
id='operation_alias' label='Сокращение'
label='Сокращение' className='w-[14rem]'
className='w-[14rem]' pattern={patterns.library_alias}
pattern={patterns.library_alias} title={`не более ${limits.library_alias_len} символов`}
title={`не более ${limits.library_alias_len} символов`} value={alias}
value={alias} onChange={event => setAlias(event.target.value)}
onChange={event => setAlias(event.target.value)} disabled={attachedID !== undefined}
disabled={syncText && attachedID !== undefined} />
/>
<Checkbox
value={syncText}
setValue={setSyncText}
label='Синхронизировать текст'
title='Брать текст из концептуальной схемы'
/>
</FlexColumn>
<TextArea <TextArea
id='operation_comment' id='operation_comment'
@ -91,7 +77,7 @@ function TabInputOperation({
rows={3} rows={3}
value={comment} value={comment}
onChange={event => setComment(event.target.value)} onChange={event => setComment(event.target.value)}
disabled={syncText && attachedID !== undefined} disabled={attachedID !== undefined}
/> />
</div> </div>

View File

@ -11,9 +11,9 @@ import { prefixes } from '@/utils/constants';
import ListConstituents from './ListConstituents'; import ListConstituents from './ListConstituents';
interface DlgDeleteCstProps extends Pick<ModalProps, 'hideWindow'> { interface DlgDeleteCstProps extends Pick<ModalProps, 'hideWindow'> {
schema: IRSForm;
selected: ConstituentaID[]; selected: ConstituentaID[];
onDelete: (items: ConstituentaID[]) => void; onDelete: (items: ConstituentaID[]) => void;
schema: IRSForm;
} }
function DlgDeleteCst({ hideWindow, selected, schema, onDelete }: DlgDeleteCstProps) { function DlgDeleteCst({ hideWindow, selected, schema, onDelete }: DlgDeleteCstProps) {

View File

@ -0,0 +1,164 @@
'use client';
import clsx from 'clsx';
import { useEffect, useMemo, useState } from 'react';
import { TabList, TabPanel, Tabs } from 'react-tabs';
import BadgeHelp from '@/components/info/BadgeHelp';
import Modal from '@/components/ui/Modal';
import Overlay from '@/components/ui/Overlay';
import TabLabel from '@/components/ui/TabLabel';
import useRSFormCache from '@/hooks/useRSFormCache';
import { HelpTopic } from '@/models/miscellaneous';
import {
ICstSubstitute,
IOperation,
IOperationSchema,
IOperationUpdateData,
OperationID,
OperationType
} from '@/models/oss';
import { PARAMETER } from '@/utils/constants';
import TabArguments from './TabArguments';
import TabOperation from './TabOperation';
import TabSynthesis from './TabSynthesis';
interface DlgEditOperationProps {
hideWindow: () => void;
oss: IOperationSchema;
target: IOperation;
onSubmit: (data: IOperationUpdateData) => void;
}
export enum TabID {
CARD = 0,
ARGUMENTS = 1,
SUBSTITUTION = 2
}
function DlgEditOperation({ hideWindow, oss, target, onSubmit }: DlgEditOperationProps) {
const [activeTab, setActiveTab] = useState(TabID.CARD);
const [alias, setAlias] = useState(target.alias);
const [title, setTitle] = useState(target.title);
const [comment, setComment] = useState(target.comment);
const [inputs, setInputs] = useState<OperationID[]>(oss.graph.expandInputs([target.id]));
const inputOperations = useMemo(() => inputs.map(id => oss.operationByID.get(id)!), [inputs, oss.operationByID]);
const schemasIDs = useMemo(
() => inputOperations.map(operation => operation.result).filter(id => id !== null),
[inputOperations]
);
const [substitutions, setSubstitutions] = useState<ICstSubstitute[]>(oss.substitutions);
const cache = useRSFormCache();
const schemas = useMemo(
() => schemasIDs.map(id => cache.getSchema(id)).filter(item => item !== undefined),
[schemasIDs, cache]
);
const isValid = useMemo(() => alias !== '', [alias]);
useEffect(() => {
cache.preload(schemasIDs);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schemasIDs]);
const handleSubmit = () => {
const data: IOperationUpdateData = {
target: target.id,
item_data: {
alias: alias,
title: title,
comment: comment
},
positions: [],
arguments: target.operation_type !== OperationType.SYNTHESIS ? undefined : inputs,
substitutions: target.operation_type !== OperationType.SYNTHESIS ? undefined : substitutions
};
onSubmit(data);
};
const cardPanel = useMemo(
() => (
<TabPanel>
<TabOperation
alias={alias}
setAlias={setAlias}
comment={comment}
setComment={setComment}
title={title}
setTitle={setTitle}
/>
</TabPanel>
),
[alias, comment, title]
);
const argumentsPanel = useMemo(
() => (
<TabPanel>
<TabArguments
target={target.id} // prettier: split-lines
oss={oss}
inputs={inputs}
setInputs={setInputs}
/>
</TabPanel>
),
[oss, target, inputs]
);
const synthesisPanel = useMemo(
() => (
<TabPanel>
<TabSynthesis
schemas={schemas}
loading={cache.loading}
error={cache.error}
substitutions={substitutions}
setSubstitutions={setSubstitutions}
/>
</TabPanel>
),
[cache.loading, cache.error, substitutions, schemas]
);
return (
<Modal
header='Редактирование операции'
submitText='Сохранить'
hideWindow={hideWindow}
canSubmit={isValid}
onSubmit={handleSubmit}
className='w-[40rem] px-6 min-h-[35rem]'
>
<Overlay position='top-0 right-0'>
<BadgeHelp topic={HelpTopic.CC_OSS} className={clsx(PARAMETER.TOOLTIP_WIDTH, 'sm:max-w-[40rem]')} offset={14} />
</Overlay>
<Tabs
selectedTabClassName='clr-selected'
className='flex flex-col'
selectedIndex={activeTab}
onSelect={setActiveTab}
>
<TabList className={clsx('mb-3 self-center', 'flex', 'border divide-x rounded-none')}>
<TabLabel title='Текстовые поля' label='Карточка' className='w-[8rem]' />
{target.operation_type === OperationType.SYNTHESIS ? (
<TabLabel title='Выбор аргументов операции' label='Аргументы' className='w-[8rem]' />
) : null}
{target.operation_type === OperationType.SYNTHESIS ? (
<TabLabel title='Таблица отождествлений' label='Отождествления' className='w-[8rem]' />
) : null}
</TabList>
{cardPanel}
{target.operation_type === OperationType.SYNTHESIS ? argumentsPanel : null}
{target.operation_type === OperationType.SYNTHESIS ? synthesisPanel : null}
</Tabs>
</Modal>
);
}
export default DlgEditOperation;

View File

@ -0,0 +1,35 @@
'use client';
import { useMemo } from 'react';
import FlexColumn from '@/components/ui/FlexColumn';
import Label from '@/components/ui/Label';
import AnimateFade from '@/components/wrap/AnimateFade';
import { IOperationSchema, OperationID } from '@/models/oss';
import PickMultiOperation from '../../components/select/PickMultiOperation';
interface TabArgumentsProps {
oss: IOperationSchema;
target: OperationID;
inputs: OperationID[];
setInputs: React.Dispatch<React.SetStateAction<OperationID[]>>;
}
function TabArguments({ oss, inputs, target, setInputs }: TabArgumentsProps) {
const potentialCycle = useMemo(() => [target, ...oss.graph.expandAllOutputs([target])], [target, oss.graph]);
const filtered = useMemo(
() => oss.items.filter(item => !potentialCycle.includes(item.id)),
[oss.items, potentialCycle]
);
return (
<AnimateFade className='cc-column'>
<FlexColumn>
<Label text={`Выбор аргументов: [ ${inputs.length} ]`} />
<PickMultiOperation items={filtered} selected={inputs} setSelected={setInputs} rows={8} />
</FlexColumn>
</AnimateFade>
);
}
export default TabArguments;

View File

@ -0,0 +1,48 @@
import TextArea from '@/components/ui/TextArea';
import TextInput from '@/components/ui/TextInput';
import AnimateFade from '@/components/wrap/AnimateFade';
import { limits, patterns } from '@/utils/constants';
interface TabOperationProps {
alias: string;
setAlias: React.Dispatch<React.SetStateAction<string>>;
title: string;
setTitle: React.Dispatch<React.SetStateAction<string>>;
comment: string;
setComment: React.Dispatch<React.SetStateAction<string>>;
}
function TabOperation({ alias, setAlias, title, setTitle, comment, setComment }: TabOperationProps) {
return (
<AnimateFade className='cc-column'>
<TextInput
id='operation_title'
label='Полное название'
value={title}
onChange={event => setTitle(event.target.value)}
/>
<div className='flex gap-6'>
<TextInput
id='operation_alias'
label='Сокращение'
className='w-[14rem]'
pattern={patterns.library_alias}
title={`не более ${limits.library_alias_len} символов`}
value={alias}
onChange={event => setAlias(event.target.value)}
/>
<TextArea
id='operation_comment'
label='Описание'
noResize
rows={3}
value={comment}
onChange={event => setComment(event.target.value)}
/>
</div>
</AnimateFade>
);
}
export default TabOperation;

View File

@ -0,0 +1,31 @@
import { ErrorData } from '@/components/info/InfoError';
import PickSubstitutions from '@/components/select/PickSubstitutions';
import DataLoader from '@/components/wrap/DataLoader';
import { ICstSubstitute } from '@/models/oss';
import { IRSForm } from '@/models/rsform';
import { prefixes } from '@/utils/constants';
interface TabSynthesisProps {
loading: boolean;
error: ErrorData;
schemas: IRSForm[];
substitutions: ICstSubstitute[];
setSubstitutions: React.Dispatch<React.SetStateAction<ICstSubstitute[]>>;
}
function TabSynthesis({ schemas, loading, error, substitutions, setSubstitutions }: TabSynthesisProps) {
return (
<DataLoader id='dlg-synthesis-tab' className='cc-column mt-3' isLoading={loading} error={error}>
<PickSubstitutions
schemas={schemas}
prefixID={prefixes.dlg_cst_substitutes_list}
rows={8}
substitutions={substitutions}
setSubstitutions={setSubstitutions}
/>
</DataLoader>
);
}
export default TabSynthesis;

View File

@ -0,0 +1 @@
export { default } from './DlgEditOperation';

View File

@ -8,7 +8,8 @@ import Modal, { ModalProps } from '@/components/ui/Modal';
import TabLabel from '@/components/ui/TabLabel'; import TabLabel from '@/components/ui/TabLabel';
import useRSFormDetails from '@/hooks/useRSFormDetails'; import useRSFormDetails from '@/hooks/useRSFormDetails';
import { LibraryItemID } from '@/models/library'; import { LibraryItemID } from '@/models/library';
import { IInlineSynthesisData, IRSForm, ISingleSubstitution } from '@/models/rsform'; import { ICstSubstitute } from '@/models/oss';
import { IInlineSynthesisData, IRSForm } from '@/models/rsform';
import TabConstituents from './TabConstituents'; import TabConstituents from './TabConstituents';
import TabSchema from './TabSchema'; import TabSchema from './TabSchema';
@ -30,7 +31,7 @@ function DlgInlineSynthesis({ hideWindow, receiver, onInlineSynthesis }: DlgInli
const [donorID, setDonorID] = useState<LibraryItemID | undefined>(undefined); const [donorID, setDonorID] = useState<LibraryItemID | undefined>(undefined);
const [selected, setSelected] = useState<LibraryItemID[]>([]); const [selected, setSelected] = useState<LibraryItemID[]>([]);
const [substitutions, setSubstitutions] = useState<ISingleSubstitution[]>([]); const [substitutions, setSubstitutions] = useState<ICstSubstitute[]>([]);
const source = useRSFormDetails({ target: donorID ? String(donorID) : undefined }); const source = useRSFormDetails({ target: donorID ? String(donorID) : undefined });
@ -44,11 +45,7 @@ function DlgInlineSynthesis({ hideWindow, receiver, onInlineSynthesis }: DlgInli
source: source.schema?.id, source: source.schema?.id,
receiver: receiver.id, receiver: receiver.id,
items: selected, items: selected,
substitutions: substitutions.map(item => ({ substitutions: substitutions
original: item.deleteRight ? item.rightCst.id : item.leftCst.id,
substitution: item.deleteRight ? item.leftCst.id : item.rightCst.id,
transfer_term: !item.deleteRight && item.takeLeftTerm
}))
}; };
onInlineSynthesis(data); onInlineSynthesis(data);
} }

View File

@ -1,11 +1,13 @@
'use client'; 'use client';
import { ErrorData } from '@/components/info/InfoError'; import { useCallback, useMemo } from 'react';
import DataLoader from '@/components/wrap/DataLoader';
import { ConstituentaID, IRSForm, ISingleSubstitution } from '@/models/rsform';
import { prefixes } from '@/utils/constants';
import PickSubstitutions from '../../components/select/PickSubstitutions'; import { ErrorData } from '@/components/info/InfoError';
import PickSubstitutions from '@/components/select/PickSubstitutions';
import DataLoader from '@/components/wrap/DataLoader';
import { ICstSubstitute } from '@/models/oss';
import { ConstituentaID, IConstituenta, IRSForm } from '@/models/rsform';
import { prefixes } from '@/utils/constants';
interface TabSubstitutionsProps { interface TabSubstitutionsProps {
receiver?: IRSForm; receiver?: IRSForm;
@ -15,8 +17,8 @@ interface TabSubstitutionsProps {
loading?: boolean; loading?: boolean;
error?: ErrorData; error?: ErrorData;
substitutions: ISingleSubstitution[]; substitutions: ICstSubstitute[];
setSubstitutions: React.Dispatch<React.SetStateAction<ISingleSubstitution[]>>; setSubstitutions: React.Dispatch<React.SetStateAction<ICstSubstitute[]>>;
} }
function TabSubstitutions({ function TabSubstitutions({
@ -30,16 +32,22 @@ function TabSubstitutions({
substitutions, substitutions,
setSubstitutions setSubstitutions
}: TabSubstitutionsProps) { }: TabSubstitutionsProps) {
const filter = useCallback(
(cst: IConstituenta) => cst.id !== source?.id || selected.includes(cst.id),
[selected, source]
);
const schemas = useMemo(() => [...(source ? [source] : []), ...(receiver ? [receiver] : [])], [source, receiver]);
return ( return (
<DataLoader id='dlg-substitutions-tab' className='cc-column' isLoading={loading} error={error} hasNoData={!source}> <DataLoader id='dlg-substitutions-tab' className='cc-column' isLoading={loading} error={error} hasNoData={!source}>
<PickSubstitutions <PickSubstitutions
items={substitutions} substitutions={substitutions}
setItems={setSubstitutions} setSubstitutions={setSubstitutions}
rows={10} rows={10}
prefixID={prefixes.cst_inline_synth_substitutes} prefixID={prefixes.cst_inline_synth_substitutes}
schema1={receiver} schemas={schemas}
schema2={source} filter={filter}
filter2={cst => selected.includes(cst.id)}
/> />
</DataLoader> </DataLoader>
); );

View File

@ -5,29 +5,23 @@ import { useMemo, useState } from 'react';
import PickSubstitutions from '@/components/select/PickSubstitutions'; import PickSubstitutions from '@/components/select/PickSubstitutions';
import Modal, { ModalProps } from '@/components/ui/Modal'; import Modal, { ModalProps } from '@/components/ui/Modal';
import { useRSForm } from '@/context/RSFormContext'; import { ICstSubstitute, ICstSubstituteData } from '@/models/oss';
import { ICstSubstituteData } from '@/models/oss'; import { IRSForm } from '@/models/rsform';
import { ISingleSubstitution } from '@/models/rsform';
import { prefixes } from '@/utils/constants'; import { prefixes } from '@/utils/constants';
interface DlgSubstituteCstProps extends Pick<ModalProps, 'hideWindow'> { interface DlgSubstituteCstProps extends Pick<ModalProps, 'hideWindow'> {
schema: IRSForm;
onSubstitute: (data: ICstSubstituteData) => void; onSubstitute: (data: ICstSubstituteData) => void;
} }
function DlgSubstituteCst({ hideWindow, onSubstitute }: DlgSubstituteCstProps) { function DlgSubstituteCst({ hideWindow, onSubstitute, schema }: DlgSubstituteCstProps) {
const { schema } = useRSForm(); const [substitutions, setSubstitutions] = useState<ICstSubstitute[]>([]);
const [substitutions, setSubstitutions] = useState<ISingleSubstitution[]>([]);
const canSubmit = useMemo(() => substitutions.length > 0, [substitutions]); const canSubmit = useMemo(() => substitutions.length > 0, [substitutions]);
function handleSubmit() { function handleSubmit() {
const data: ICstSubstituteData = { const data: ICstSubstituteData = {
substitutions: substitutions.map(item => ({ substitutions: substitutions
original: item.deleteRight ? item.rightCst.id : item.leftCst.id,
substitution: item.deleteRight ? item.leftCst.id : item.rightCst.id,
transfer_term: !item.deleteRight && item.takeLeftTerm
}))
}; };
onSubstitute(data); onSubstitute(data);
} }
@ -43,12 +37,12 @@ function DlgSubstituteCst({ hideWindow, onSubstitute }: DlgSubstituteCstProps) {
className={clsx('w-[40rem]', 'px-6 pb-3')} className={clsx('w-[40rem]', 'px-6 pb-3')}
> >
<PickSubstitutions <PickSubstitutions
items={substitutions} allowSelfSubstitution
setItems={setSubstitutions} substitutions={substitutions}
setSubstitutions={setSubstitutions}
rows={6} rows={6}
prefixID={prefixes.dlg_cst_substitutes_list} prefixID={prefixes.dlg_cst_substitutes_list}
schema1={schema} schemas={[schema]}
schema2={schema}
/> />
</Modal> </Modal>
); );

View File

@ -0,0 +1,82 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { getRSFormDetails } from '@/backend/rsforms';
import { type ErrorData } from '@/components/info/InfoError';
import { LibraryItemID } from '@/models/library';
import { ConstituentaID, IRSForm, IRSFormData } from '@/models/rsform';
import { RSFormLoader } from '@/models/RSFormLoader';
function useRSFormCache() {
const [cache, setCache] = useState<IRSForm[]>([]);
const [pending, setPending] = useState<LibraryItemID[]>([]);
const [processing, setProcessing] = useState<LibraryItemID[]>([]);
const loading = useMemo(() => pending.length > 0 || processing.length > 0, [pending, processing]);
const [error, setError] = useState<ErrorData>(undefined);
function setSchema(data: IRSFormData) {
const schema = new RSFormLoader(data).produceRSForm();
setCache(prev => [...prev, schema]);
}
const getSchema = useCallback((id: LibraryItemID) => cache.find(item => item.id === id), [cache]);
const getSchemaByCst = useCallback(
(id: ConstituentaID) => {
for (const schema of cache) {
const cst = schema.items.find(cst => cst.id === id);
if (cst) {
return schema;
}
}
return undefined;
},
[cache]
);
const getConstituenta = useCallback(
(id: ConstituentaID) => {
for (const schema of cache) {
const cst = schema.items.find(cst => cst.id === id);
if (cst) {
return cst;
}
}
return undefined;
},
[cache]
);
const preload = useCallback(
(target: LibraryItemID[]) => setPending(prev => [...prev, ...target.filter(id => !prev.includes(id))]),
[]
);
useEffect(() => {
const ids = pending.filter(id => !processing.includes(id) && !cache.find(schema => schema.id === id));
if (ids.length === 0) {
return;
}
setProcessing(prev => [...prev, ...ids]);
setPending([]);
ids.forEach(id =>
getRSFormDetails(String(id), '', {
showError: false,
onError: error => {
setProcessing(prev => prev.filter(item => item !== id));
setError(error);
},
onSuccess: data => {
setProcessing(prev => prev.filter(item => item !== id));
setSchema(data);
}
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pending]);
return { preload, getSchema, getConstituenta, getSchemaByCst, loading, error, setError };
}
export default useRSFormCache;

View File

@ -4,7 +4,7 @@
import { Graph } from './Graph'; import { Graph } from './Graph';
import { ILibraryItem, ILibraryItemData, LibraryItemID } from './library'; import { ILibraryItem, ILibraryItemData, LibraryItemID } from './library';
import { ConstituentaID } from './rsform'; import { ConstituentaID, IConstituenta } from './rsform';
/** /**
* Represents {@link IOperation} identifier type. * Represents {@link IOperation} identifier type.
@ -30,7 +30,6 @@ export interface IOperation {
alias: string; alias: string;
title: string; title: string;
comment: string; comment: string;
sync_text: boolean;
position_x: number; position_x: number;
position_y: number; position_y: number;
@ -63,12 +62,28 @@ export interface ITargetOperation extends IPositionsData {
export interface IOperationCreateData extends IPositionsData { export interface IOperationCreateData extends IPositionsData {
item_data: Pick< item_data: Pick<
IOperation, IOperation,
'alias' | 'operation_type' | 'title' | 'comment' | 'position_x' | 'position_y' | 'result' | 'sync_text' 'alias' | 'operation_type' | 'title' | 'comment' | 'position_x' | 'position_y' | 'result'
>; >;
arguments: OperationID[] | undefined; arguments: OperationID[] | undefined;
create_schema: boolean; create_schema: boolean;
} }
/**
* Represents {@link IOperation} data, used in update process.
*/
export interface IOperationUpdateData extends ITargetOperation {
item_data: Pick<IOperation, 'alias' | 'title' | 'comment'>;
arguments: OperationID[] | undefined;
substitutions: ICstSubstitute[] | undefined;
}
/**
* Represents {@link IOperation} data, used in setInput process.
*/
export interface IOperationSetInputData extends ITargetOperation {
input: LibraryItemID | null;
}
/** /**
* Represents {@link IOperation} Argument. * Represents {@link IOperation} Argument.
*/ */
@ -83,7 +98,6 @@ export interface IArgument {
export interface ICstSubstitute { export interface ICstSubstitute {
original: ConstituentaID; original: ConstituentaID;
substitution: ConstituentaID; substitution: ConstituentaID;
transfer_term: boolean;
} }
/** /**
@ -93,6 +107,16 @@ export interface ICstSubstituteData {
substitutions: ICstSubstitute[]; substitutions: ICstSubstitute[];
} }
/**
* Represents substitution for multi synthesis table.
*/
export interface IMultiSubstitution {
original_source: ILibraryItem | undefined;
original: IConstituenta | undefined;
substitution: IConstituenta | undefined;
substitution_source: ILibraryItem | undefined;
}
/** /**
* Represents {@link ICstSubstitute} extended data. * Represents {@link ICstSubstitute} extended data.
*/ */

View File

@ -241,13 +241,12 @@ export interface IVersionCreatedResponse {
} }
/** /**
* Represents single substitution for synthesis table. * Represents single substitution for binary synthesis table.
*/ */
export interface ISingleSubstitution { export interface IBinarySubstitution {
leftCst: IConstituenta; leftCst: IConstituenta;
rightCst: IConstituenta; rightCst: IConstituenta;
deleteRight: boolean; deleteRight: boolean;
takeLeftTerm: boolean;
} }
/** /**

View File

@ -4,8 +4,6 @@ import {
IconGenerateNames, IconGenerateNames,
IconGenerateStructure, IconGenerateStructure,
IconInlineSynthesis, IconInlineSynthesis,
IconKeepAliasOn,
IconKeepTermOn,
IconReplace, IconReplace,
IconSortList, IconSortList,
IconTemplates IconTemplates
@ -60,13 +58,7 @@ function HelpRSLangOperations() {
</h2> </h2>
<p> <p>
Формирование таблицы отождествлений и ее применение к текущей схеме. В результате будет удален ряд конституент и Формирование таблицы отождествлений и ее применение к текущей схеме. В результате будет удален ряд конституент и
их вхождения заменены на другие. Возможна настройка какой термин использовать для оставшихся конституент их вхождения заменены на другие.
<li>
<IconKeepAliasOn size='1.25rem' className='inline-icon' /> выбор сохраняемой конституенты
</li>
<li>
<IconKeepTermOn size='1.25rem' className='inline-icon' /> выбор сохраняемого термина
</li>
</p> </p>
<h2> <h2>

View File

@ -1,7 +1,6 @@
'use client'; 'use client';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import { IconConnect, IconDestroy, IconEdit2, IconExecute, IconNewItem, IconRSForm } from '@/components/Icons'; import { IconConnect, IconDestroy, IconEdit2, IconExecute, IconNewItem, IconRSForm } from '@/components/Icons';
import Dropdown from '@/components/ui/Dropdown'; import Dropdown from '@/components/ui/Dropdown';
@ -23,12 +22,45 @@ interface NodeContextMenuProps extends ContextMenuData {
onHide: () => void; onHide: () => void;
onDelete: (target: OperationID) => void; onDelete: (target: OperationID) => void;
onCreateInput: (target: OperationID) => void; onCreateInput: (target: OperationID) => void;
onEditSchema: (target: OperationID) => void;
onEditOperation: (target: OperationID) => void;
onExecuteOperation: (target: OperationID) => void;
} }
function NodeContextMenu({ operation, cursorX, cursorY, onHide, onDelete, onCreateInput }: NodeContextMenuProps) { function NodeContextMenu({
operation,
cursorX,
cursorY,
onHide,
onDelete,
onCreateInput,
onEditSchema,
onEditOperation,
onExecuteOperation
}: NodeContextMenuProps) {
const controller = useOssEdit(); const controller = useOssEdit();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const ref = useRef(null); const ref = useRef(null);
const readyForSynthesis = useMemo(() => {
if (operation.operation_type !== OperationType.SYNTHESIS) {
return false;
}
if (!controller.schema || operation.result) {
return false;
}
const argumentIDs = controller.schema.graph.expandInputs([operation.id]);
if (!argumentIDs || argumentIDs.length < 2) {
return false;
}
const argumentOperations = argumentIDs.map(id => controller.schema!.operationByID.get(id)!);
if (argumentOperations.some(item => item.result === null)) {
return false;
}
return true;
}, [operation, controller.schema]);
const handleHide = useCallback(() => { const handleHide = useCallback(() => {
setIsOpen(false); setIsOpen(false);
@ -44,13 +76,13 @@ function NodeContextMenu({ operation, cursorX, cursorY, onHide, onDelete, onCrea
}; };
const handleEditSchema = () => { const handleEditSchema = () => {
toast.error('Not implemented');
handleHide(); handleHide();
onEditSchema(operation.id);
}; };
const handleEditOperation = () => { const handleEditOperation = () => {
toast.error('Not implemented');
handleHide(); handleHide();
onEditOperation(operation.id);
}; };
const handleDeleteOperation = () => { const handleDeleteOperation = () => {
@ -64,8 +96,8 @@ function NodeContextMenu({ operation, cursorX, cursorY, onHide, onDelete, onCrea
}; };
const handleRunSynthesis = () => { const handleRunSynthesis = () => {
toast.error('Not implemented');
handleHide(); handleHide();
onExecuteOperation(operation.id);
}; };
return ( return (
@ -97,9 +129,9 @@ function NodeContextMenu({ operation, cursorX, cursorY, onHide, onDelete, onCrea
onClick={handleCreateSchema} onClick={handleCreateSchema}
/> />
) : null} ) : null}
{controller.isMutable && !operation.result && operation.operation_type === OperationType.INPUT ? ( {controller.isMutable && operation.operation_type === OperationType.INPUT ? (
<DropdownButton <DropdownButton
text='Загрузить схему' text={!operation.result ? 'Загрузить схему' : 'Изменить схему'}
title='Выбрать схему для загрузки' title='Выбрать схему для загрузки'
icon={<IconConnect size='1rem' className='icon-primary' />} icon={<IconConnect size='1rem' className='icon-primary' />}
disabled={controller.isProcessing} disabled={controller.isProcessing}
@ -109,9 +141,13 @@ function NodeContextMenu({ operation, cursorX, cursorY, onHide, onDelete, onCrea
{controller.isMutable && !operation.result && operation.operation_type === OperationType.SYNTHESIS ? ( {controller.isMutable && !operation.result && operation.operation_type === OperationType.SYNTHESIS ? (
<DropdownButton <DropdownButton
text='Выполнить синтез' text='Выполнить синтез'
title='Выполнить операцию и получить синтезированную КС' title={
readyForSynthesis
? 'Выполнить операцию и получить синтезированную КС'
: 'Необходимо предоставить все аргументы'
}
icon={<IconExecute size='1rem' className='icon-green' />} icon={<IconExecute size='1rem' className='icon-green' />}
disabled={controller.isProcessing} disabled={controller.isProcessing || !readyForSynthesis}
onClick={handleRunSynthesis} onClick={handleRunSynthesis}
/> />
) : null} ) : null}

View File

@ -28,9 +28,7 @@ function OperationNode(node: OssNodeInternal) {
noHover noHover
title='Связанная КС' title='Связанная КС'
hideTitle={!controller.showTooltip} hideTitle={!controller.showTooltip}
onClick={() => { onClick={handleOpenSchema}
handleOpenSchema();
}}
disabled={!hasFile} disabled={!hasFile}
/> />
</Overlay> </Overlay>

View File

@ -148,6 +148,34 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
[controller, getPositions] [controller, getPositions]
); );
const handleEditSchema = useCallback(
(target: OperationID) => {
controller.promptEditInput(target, getPositions());
},
[controller, getPositions]
);
const handleEditOperation = useCallback(
(target: OperationID) => {
controller.promptEditOperation(target, getPositions());
},
[controller, getPositions]
);
const handleExecuteOperation = useCallback(
(target: OperationID) => {
controller.executeOperation(target, getPositions());
},
[controller, getPositions]
);
const handleExecuteSelected = useCallback(() => {
if (controller.selected.length !== 1) {
return;
}
handleExecuteOperation(controller.selected[0]);
}, [controller, handleExecuteOperation]);
const handleFitView = useCallback(() => { const handleFitView = useCallback(() => {
flow.fitView({ duration: PARAMETER.zoomDuration }); flow.fitView({ duration: PARAMETER.zoomDuration });
}, [flow]); }, [flow]);
@ -213,6 +241,17 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
handleContextMenuHide(); handleContextMenuHide();
}, [handleContextMenuHide]); }, [handleContextMenuHide]);
const handleNodeClick = useCallback(
(event: CProps.EventMouse, node: OssNode) => {
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
event.stopPropagation();
handleEditOperation(Number(node.id));
}
},
[handleEditOperation]
);
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) { function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (controller.isProcessing) { if (controller.isProcessing) {
return; return;
@ -226,6 +265,12 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
handleSavePositions(); handleSavePositions();
return; return;
} }
if ((event.ctrlKey || event.metaKey) && event.key === 'q') {
event.preventDefault();
event.stopPropagation();
handleCreateOperation();
return;
}
if (event.key === 'Delete') { if (event.key === 'Delete') {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@ -252,8 +297,9 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
edges={edges} edges={edges}
onNodesChange={handleNodesChange} onNodesChange={handleNodesChange}
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
fitView onNodeClick={handleNodeClick}
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
fitView
nodeTypes={OssNodeTypes} nodeTypes={OssNodeTypes}
maxZoom={2} maxZoom={2}
minZoom={0.75} minZoom={0.75}
@ -266,7 +312,17 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
{showGrid ? <Background gap={10} /> : null} {showGrid ? <Background gap={10} /> : null}
</ReactFlow> </ReactFlow>
), ),
[nodes, edges, handleNodesChange, handleContextMenu, handleClickCanvas, onEdgesChange, OssNodeTypes, showGrid] [
nodes,
edges,
handleNodesChange,
handleContextMenu,
handleClickCanvas,
onEdgesChange,
handleNodeClick,
OssNodeTypes,
showGrid
]
); );
return ( return (
@ -280,6 +336,8 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
onFitView={handleFitView} onFitView={handleFitView}
onCreate={handleCreateOperation} onCreate={handleCreateOperation}
onDelete={handleDeleteSelected} onDelete={handleDeleteSelected}
onEdit={() => handleEditOperation(controller.selected[0])}
onExecute={handleExecuteSelected}
onResetPositions={handleResetPositions} onResetPositions={handleResetPositions}
onSavePositions={handleSavePositions} onSavePositions={handleSavePositions}
onSaveImage={handleSaveImage} onSaveImage={handleSaveImage}
@ -293,6 +351,9 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
onHide={handleContextMenuHide} onHide={handleContextMenuHide}
onDelete={handleDeleteOperation} onDelete={handleDeleteOperation}
onCreateInput={handleCreateInput} onCreateInput={handleCreateInput}
onEditSchema={handleEditSchema}
onEditOperation={handleEditOperation}
onExecuteOperation={handleExecuteOperation}
{...menuProps} {...menuProps}
/> />
) : null} ) : null}

View File

@ -1,9 +1,14 @@
'use client';
import clsx from 'clsx'; import clsx from 'clsx';
import { useMemo } from 'react';
import { import {
IconAnimation, IconAnimation,
IconAnimationOff, IconAnimationOff,
IconDestroy, IconDestroy,
IconEdit2,
IconExecute,
IconFitImage, IconFitImage,
IconGrid, IconGrid,
IconImage, IconImage,
@ -16,6 +21,7 @@ import {
import BadgeHelp from '@/components/info/BadgeHelp'; import BadgeHelp from '@/components/info/BadgeHelp';
import MiniButton from '@/components/ui/MiniButton'; import MiniButton from '@/components/ui/MiniButton';
import { HelpTopic } from '@/models/miscellaneous'; import { HelpTopic } from '@/models/miscellaneous';
import { OperationType } from '@/models/oss';
import { PARAMETER } from '@/utils/constants'; import { PARAMETER } from '@/utils/constants';
import { prepareTooltip } from '@/utils/labels'; import { prepareTooltip } from '@/utils/labels';
@ -28,6 +34,8 @@ interface ToolbarOssGraphProps {
edgeStraight: boolean; edgeStraight: boolean;
onCreate: () => void; onCreate: () => void;
onDelete: () => void; onDelete: () => void;
onEdit: () => void;
onExecute: () => void;
onFitView: () => void; onFitView: () => void;
onSaveImage: () => void; onSaveImage: () => void;
onSavePositions: () => void; onSavePositions: () => void;
@ -44,6 +52,8 @@ function ToolbarOssGraph({
edgeStraight, edgeStraight,
onCreate, onCreate,
onDelete, onDelete,
onEdit,
onExecute,
onFitView, onFitView,
onSaveImage, onSaveImage,
onSavePositions, onSavePositions,
@ -53,10 +63,40 @@ function ToolbarOssGraph({
toggleEdgeStraight toggleEdgeStraight
}: ToolbarOssGraphProps) { }: ToolbarOssGraphProps) {
const controller = useOssEdit(); const controller = useOssEdit();
const selectedOperation = useMemo(
() => controller.schema?.operationByID.get(controller.selected[0]),
[controller.selected, controller.schema]
);
const readyForSynthesis = useMemo(() => {
if (!selectedOperation || selectedOperation.operation_type !== OperationType.SYNTHESIS) {
return false;
}
if (!controller.schema || selectedOperation.result) {
return false;
}
const argumentIDs = controller.schema.graph.expandInputs([selectedOperation.id]);
if (!argumentIDs || argumentIDs.length < 2) {
return false;
}
const argumentOperations = argumentIDs.map(id => controller.schema!.operationByID.get(id)!);
if (argumentOperations.some(item => item.result === null)) {
return false;
}
return true;
}, [selectedOperation, controller.schema]);
return ( return (
<div className='flex flex-col items-center'> <div className='flex flex-col items-center'>
<div className='cc-icons'> <div className='cc-icons'>
<MiniButton
title='Сбросить изменения'
icon={<IconReset size='1.25rem' className='icon-primary' />}
disabled={!isModified}
onClick={onResetPositions}
/>
<MiniButton <MiniButton
icon={<IconFitImage size='1.25rem' className='icon-primary' />} icon={<IconFitImage size='1.25rem' className='icon-primary' />}
title='Сбросить вид' title='Сбросить вид'
@ -108,7 +148,6 @@ function ToolbarOssGraph({
</div> </div>
{controller.isMutable ? ( {controller.isMutable ? (
<div className='cc-icons'> <div className='cc-icons'>
{' '}
<MiniButton <MiniButton
titleHtml={prepareTooltip('Сохранить изменения', 'Ctrl + S')} titleHtml={prepareTooltip('Сохранить изменения', 'Ctrl + S')}
icon={<IconSave size='1.25rem' className='icon-primary' />} icon={<IconSave size='1.25rem' className='icon-primary' />}
@ -116,19 +155,25 @@ function ToolbarOssGraph({
onClick={onSavePositions} onClick={onSavePositions}
/> />
<MiniButton <MiniButton
title='Сбросить изменения' titleHtml={prepareTooltip('Новая операция', 'Ctrl + Q')}
icon={<IconReset size='1.25rem' className='icon-primary' />}
disabled={!isModified}
onClick={onResetPositions}
/>
<MiniButton
title='Новая операция'
icon={<IconNewItem size='1.25rem' className='icon-green' />} icon={<IconNewItem size='1.25rem' className='icon-green' />}
disabled={controller.isProcessing} disabled={controller.isProcessing}
onClick={onCreate} onClick={onCreate}
/> />
<MiniButton <MiniButton
title='Удалить выбранную' title='Выполнить операцию'
icon={<IconExecute size='1.25rem' className='icon-green' />}
disabled={controller.isProcessing || controller.selected.length !== 1 || !readyForSynthesis}
onClick={onExecute}
/>
<MiniButton
titleHtml={prepareTooltip('Редактировать выбранную', 'Ctrl + клик')}
icon={<IconEdit2 size='1.25rem' className='icon-primary' />}
disabled={controller.selected.length !== 1 || controller.isProcessing}
onClick={onEdit}
/>
<MiniButton
titleHtml={prepareTooltip('Удалить выбранную', 'Delete')}
icon={<IconDestroy size='1.25rem' className='icon-red' />} icon={<IconDestroy size='1.25rem' className='icon-red' />}
disabled={controller.selected.length !== 1 || controller.isProcessing} disabled={controller.selected.length !== 1 || controller.isProcessing}
onClick={onDelete} onClick={onDelete}
@ -138,5 +183,5 @@ function ToolbarOssGraph({
</div> </div>
); );
} }
//IconExecute
export default ToolbarOssGraph; export default ToolbarOssGraph;

View File

@ -10,12 +10,21 @@ import { useAuth } from '@/context/AuthContext';
import { useConceptOptions } from '@/context/ConceptOptionsContext'; import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { useConceptNavigation } from '@/context/NavigationContext'; import { useConceptNavigation } from '@/context/NavigationContext';
import { useOSS } from '@/context/OssContext'; import { useOSS } from '@/context/OssContext';
import DlgChangeInputSchema from '@/dialogs/DlgChangeInputSchema';
import DlgChangeLocation from '@/dialogs/DlgChangeLocation'; import DlgChangeLocation from '@/dialogs/DlgChangeLocation';
import DlgCreateOperation from '@/dialogs/DlgCreateOperation'; import DlgCreateOperation from '@/dialogs/DlgCreateOperation';
import DlgEditEditors from '@/dialogs/DlgEditEditors'; import DlgEditEditors from '@/dialogs/DlgEditEditors';
import { AccessPolicy } from '@/models/library'; import DlgEditOperation from '@/dialogs/DlgEditOperation';
import { AccessPolicy, LibraryItemID } from '@/models/library';
import { Position2D } from '@/models/miscellaneous'; import { Position2D } from '@/models/miscellaneous';
import { IOperationCreateData, IOperationPosition, IOperationSchema, OperationID } from '@/models/oss'; import {
IOperationCreateData,
IOperationPosition,
IOperationSchema,
IOperationSetInputData,
IOperationUpdateData,
OperationID
} from '@/models/oss';
import { UserID, UserLevel } from '@/models/user'; import { UserID, UserLevel } from '@/models/user';
import { information } from '@/utils/labels'; import { information } from '@/utils/labels';
@ -45,6 +54,9 @@ export interface IOssEditContext {
promptCreateOperation: (x: number, y: number, positions: IOperationPosition[]) => void; promptCreateOperation: (x: number, y: number, positions: IOperationPosition[]) => void;
deleteOperation: (target: OperationID, positions: IOperationPosition[]) => void; deleteOperation: (target: OperationID, positions: IOperationPosition[]) => void;
createInput: (target: OperationID, positions: IOperationPosition[]) => void; createInput: (target: OperationID, positions: IOperationPosition[]) => void;
promptEditInput: (target: OperationID, positions: IOperationPosition[]) => void;
promptEditOperation: (target: OperationID, positions: IOperationPosition[]) => void;
executeOperation: (target: OperationID, positions: IOperationPosition[]) => void;
} }
const OssEditContext = createContext<IOssEditContext | null>(null); const OssEditContext = createContext<IOssEditContext | null>(null);
@ -79,10 +91,17 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
const [showEditEditors, setShowEditEditors] = useState(false); const [showEditEditors, setShowEditEditors] = useState(false);
const [showEditLocation, setShowEditLocation] = useState(false); const [showEditLocation, setShowEditLocation] = useState(false);
const [showEditInput, setShowEditInput] = useState(false);
const [showEditOperation, setShowEditOperation] = useState(false);
const [showCreateOperation, setShowCreateOperation] = useState(false); const [showCreateOperation, setShowCreateOperation] = useState(false);
const [insertPosition, setInsertPosition] = useState<Position2D>({ x: 0, y: 0 }); const [insertPosition, setInsertPosition] = useState<Position2D>({ x: 0, y: 0 });
const [positions, setPositions] = useState<IOperationPosition[]>([]); const [positions, setPositions] = useState<IOperationPosition[]>([]);
const [targetOperationID, setTargetOperationID] = useState<OperationID | undefined>(undefined);
const targetOperation = useMemo(
() => (targetOperationID ? model.schema?.operationByID.get(targetOperationID) : undefined),
[model, targetOperationID]
);
useLayoutEffect( useLayoutEffect(
() => () =>
@ -197,9 +216,26 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
const handleCreateOperation = useCallback( const handleCreateOperation = useCallback(
(data: IOperationCreateData) => { (data: IOperationCreateData) => {
data.positions = positions;
data.item_data.position_x = insertPosition.x;
data.item_data.position_y = insertPosition.y;
model.createOperation(data, operation => toast.success(information.newOperation(operation.alias))); model.createOperation(data, operation => toast.success(information.newOperation(operation.alias)));
}, },
[model] [model, positions, insertPosition]
);
const promptEditOperation = useCallback((target: OperationID, positions: IOperationPosition[]) => {
setPositions(positions);
setTargetOperationID(target);
setShowEditOperation(true);
}, []);
const handleEditOperation = useCallback(
(data: IOperationUpdateData) => {
data.positions = positions;
model.updateOperation(data, () => toast.success(information.changesSaved));
},
[model, positions]
); );
const deleteOperation = useCallback( const deleteOperation = useCallback(
@ -221,6 +257,38 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
[model, router] [model, router]
); );
const promptEditInput = useCallback((target: OperationID, positions: IOperationPosition[]) => {
setPositions(positions);
setTargetOperationID(target);
setShowEditInput(true);
}, []);
const setTargetInput = useCallback(
(newInput: LibraryItemID | undefined) => {
if (!targetOperationID) {
return;
}
const data: IOperationSetInputData = {
target: targetOperationID,
positions: positions,
input: newInput ?? null
};
model.setInput(data, () => toast.success(information.changesSaved));
},
[model, targetOperationID, positions]
);
const executeOperation = useCallback(
(target: OperationID, positions: IOperationPosition[]) => {
const data = {
target: target,
positions: positions
};
model.executeOperation(data, () => toast.success(information.operationExecuted));
},
[model]
);
return ( return (
<OssEditContext.Provider <OssEditContext.Provider
value={{ value={{
@ -246,7 +314,10 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
savePositions, savePositions,
promptCreateOperation, promptCreateOperation,
deleteOperation, deleteOperation,
createInput createInput,
promptEditInput,
promptEditOperation,
executeOperation
}} }}
> >
{model.schema ? ( {model.schema ? (
@ -269,11 +340,25 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
<DlgCreateOperation <DlgCreateOperation
hideWindow={() => setShowCreateOperation(false)} hideWindow={() => setShowCreateOperation(false)}
oss={model.schema} oss={model.schema}
positions={positions}
insertPosition={insertPosition}
onCreate={handleCreateOperation} onCreate={handleCreateOperation}
/> />
) : null} ) : null}
{showEditInput ? (
<DlgChangeInputSchema
hideWindow={() => setShowEditInput(false)}
oss={model.schema}
target={targetOperation!}
onSubmit={setTargetInput}
/>
) : null}
{showEditOperation ? (
<DlgEditOperation
hideWindow={() => setShowEditOperation(false)}
oss={model.schema}
target={targetOperation!}
onSubmit={handleEditOperation}
/>
) : null}
</AnimatePresence> </AnimatePresence>
) : null} ) : null}

View File

@ -646,6 +646,7 @@ export const RSEditState = ({
) : null} ) : null}
{showSubstitute ? ( {showSubstitute ? (
<DlgSubstituteCst <DlgSubstituteCst
schema={model.schema}
hideWindow={() => setShowSubstitute(false)} // prettier: split lines hideWindow={() => setShowSubstitute(false)} // prettier: split lines
onSubstitute={handleSubstituteCst} onSubstitute={handleSubstituteCst}
/> />

View File

@ -940,6 +940,8 @@ export const information = {
versionDestroyed: 'Версия удалена', versionDestroyed: 'Версия удалена',
itemDestroyed: 'Схема удалена', itemDestroyed: 'Схема удалена',
operationDestroyed: 'Операция удалена', operationDestroyed: 'Операция удалена',
operationExecuted: 'Операция выполнена',
allOperationExecuted: 'Все операции выполнены',
constituentsDestroyed: (aliases: string) => `Конституенты удалены: ${aliases}` constituentsDestroyed: (aliases: string) => `Конституенты удалены: ${aliases}`
}; };
@ -949,7 +951,8 @@ export const information = {
export const errors = { export const errors = {
astFailed: 'Невозможно построить дерево разбора', astFailed: 'Невозможно построить дерево разбора',
passwordsMismatch: 'Пароли не совпадают', passwordsMismatch: 'Пароли не совпадают',
imageFailed: 'Ошибка при создании изображения' imageFailed: 'Ошибка при создании изображения',
reuseOriginal: 'Повторное использование удаляемой конституенты при отождествлении'
}; };
/** /**