F: Rename Nominal relation and improve UI

This commit is contained in:
Ivan 2025-08-21 21:26:40 +03:00
parent 14cda60b0d
commit 1555a1bf92
35 changed files with 485 additions and 451 deletions

11
.vscode/settings.json vendored
View File

@ -184,6 +184,9 @@
"Айзенштат", "Айзенштат",
"Акименков", "Акименков",
"Астрина", "Астрина",
"Атрибутирование",
"Атрибутирующая",
"Атрибутирующие",
"Ашихмин", "Ашихмин",
"Биективная", "Биективная",
"биективной", "биективной",
@ -222,10 +225,10 @@
"неинтерпретируемый", "неинтерпретируемый",
"неитерируемого", "неитерируемого",
"Никанорова", "Никанорова",
"Номеноид", "Номиноид",
"номеноида", "номиноида",
"номеноидом", "номиноидом",
"Номеноиды", "Номиноиды",
"операционализации", "операционализации",
"операционализированных", "операционализированных",
"Оргтеор", "Оргтеор",

View File

@ -4,7 +4,7 @@
from typing import Optional from typing import Optional
from apps.library.models import LibraryItem from apps.library.models import LibraryItem
from apps.rsform.models import Association, Constituenta, CstType, OrderManager, RSFormCached from apps.rsform.models import Attribution, Constituenta, CstType, OrderManager, RSFormCached
from .Argument import Argument from .Argument import Argument
from .Inheritance import Inheritance from .Inheritance import Inheritance
@ -318,16 +318,16 @@ class OperationSchemaCached:
mapping={} mapping={}
) )
def after_create_association(self, schemaID: int, associations: list[Association], def after_create_attribution(self, schemaID: int, associations: list[Attribution],
exclude: Optional[list[int]] = None) -> None: exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions when association is created. ''' ''' Trigger cascade resolutions when Attribution is created. '''
operation = self.cache.get_operation(schemaID) operation = self.cache.get_operation(schemaID)
self.engine.on_inherit_association(operation.pk, associations, exclude) self.engine.on_inherit_attribution(operation.pk, associations, exclude)
def before_delete_association(self, schemaID: int, associations: list[Association]) -> None: def before_delete_attribution(self, schemaID: int, associations: list[Attribution]) -> None:
''' Trigger cascade resolutions when association is deleted. ''' ''' Trigger cascade resolutions when Attribution is deleted. '''
operation = self.cache.get_operation(schemaID) operation = self.cache.get_operation(schemaID)
self.engine.on_delete_association(operation.pk, associations) self.engine.on_delete_attribution(operation.pk, associations)
def _on_add_substitutions(self, schema: Optional[RSFormCached], added: list[Substitution]) -> None: def _on_add_substitutions(self, schema: Optional[RSFormCached], added: list[Substitution]) -> None:
''' Trigger cascade resolutions when Constituenta substitution is added. ''' ''' Trigger cascade resolutions when Constituenta substitution is added. '''

View File

@ -3,7 +3,7 @@ from typing import Optional
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from apps.rsform.models import INSERT_LAST, Association, Constituenta, CstType, RSFormCached from apps.rsform.models import INSERT_LAST, Attribution, Constituenta, CstType, RSFormCached
from .Inheritance import Inheritance from .Inheritance import Inheritance
from .Operation import Operation from .Operation import Operation
@ -126,10 +126,10 @@ class PropagationEngine:
mapping=new_mapping mapping=new_mapping
) )
def on_inherit_association(self, operationID: int, def on_inherit_attribution(self, operationID: int,
items: list[Association], items: list[Attribution],
exclude: Optional[list[int]] = None) -> None: exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions when association is inherited. ''' ''' Trigger cascade resolutions when Attribution is inherited. '''
children = self.cache.extend_graph.outputs[operationID] children = self.cache.extend_graph.outputs[operationID]
if not children: if not children:
return return
@ -137,7 +137,7 @@ class PropagationEngine:
if not exclude or child_id not in exclude: if not exclude or child_id not in exclude:
self.inherit_association(child_id, items) self.inherit_association(child_id, items)
def inherit_association(self, target: int, items: list[Association]) -> None: def inherit_association(self, target: int, items: list[Attribution]) -> None:
''' Execute inheritance of Associations. ''' ''' Execute inheritance of Associations. '''
operation = self.cache.operation_by_id[target] operation = self.cache.operation_by_id[target]
if operation.result is None or not items: if operation.result is None or not items:
@ -146,27 +146,27 @@ class PropagationEngine:
self.cache.ensure_loaded_subs() self.cache.ensure_loaded_subs()
existing_associations = set( existing_associations = set(
Association.objects.filter( Attribution.objects.filter(
container__schema_id=operation.result_id, container__schema_id=operation.result_id,
).values_list('container_id', 'associate_id') ).values_list('container_id', 'attribute_id')
) )
new_associations: list[Association] = [] new_associations: list[Attribution] = []
for assoc in items: for assoc in items:
new_container = self.cache.get_inheritor(assoc.container_id, target) new_container = self.cache.get_inheritor(assoc.container_id, target)
new_associate = self.cache.get_inheritor(assoc.associate_id, target) new_attribute = self.cache.get_inheritor(assoc.attribute_id, target)
if new_container is None or new_associate is None \ if new_container is None or new_attribute is None \
or new_associate == new_container \ or new_attribute == new_container \
or (new_container, new_associate) in existing_associations: or (new_container, new_attribute) in existing_associations:
continue continue
new_associations.append(Association( new_associations.append(Attribution(
container_id=new_container, container_id=new_container,
associate_id=new_associate attribute_id=new_attribute
)) ))
if new_associations: if new_associations:
new_associations = Association.objects.bulk_create(new_associations) new_associations = Attribution.objects.bulk_create(new_associations)
self.on_inherit_association(target, new_associations) self.on_inherit_attribution(target, new_associations)
def on_before_substitute(self, operationID: int, substitutions: CstSubstitution) -> None: def on_before_substitute(self, operationID: int, substitutions: CstSubstitution) -> None:
''' Trigger cascade resolutions when Constituenta substitution is executed. ''' ''' Trigger cascade resolutions when Constituenta substitution is executed. '''
@ -185,8 +185,8 @@ class PropagationEngine:
self.on_before_substitute(child_operation.pk, new_substitutions) self.on_before_substitute(child_operation.pk, new_substitutions)
child_schema.substitute(new_substitutions) child_schema.substitute(new_substitutions)
def on_delete_association(self, operationID: int, associations: list[Association]) -> None: def on_delete_attribution(self, operationID: int, associations: list[Attribution]) -> None:
''' Trigger cascade resolutions when association is deleted. ''' ''' Trigger cascade resolutions when Attribution is deleted. '''
children = self.cache.extend_graph.outputs[operationID] children = self.cache.extend_graph.outputs[operationID]
if not children: if not children:
return return
@ -197,21 +197,21 @@ class PropagationEngine:
if child_schema is None: if child_schema is None:
continue continue
deleted: list[Association] = [] deleted: list[Attribution] = []
for assoc in associations: for attr in associations:
new_container = self.cache.get_inheritor(assoc.container_id, child_id) new_container = self.cache.get_inheritor(attr.container_id, child_id)
new_associate = self.cache.get_inheritor(assoc.associate_id, child_id) new_attribute = self.cache.get_inheritor(attr.attribute_id, child_id)
if new_container is None or new_associate is None: if new_container is None or new_attribute is None:
continue continue
deleted_assoc = Association.objects.filter( deleted_assoc = Attribution.objects.filter(
container=new_container, container=new_container,
associate=new_associate attribute=new_attribute
) )
if deleted_assoc.exists(): if deleted_assoc.exists():
deleted.append(deleted_assoc[0]) deleted.append(deleted_assoc[0])
if deleted: if deleted:
self.on_delete_association(child_id, deleted) self.on_delete_attribution(child_id, deleted)
Association.objects.filter(pk__in=[assoc.pk for assoc in deleted]).delete() Attribution.objects.filter(pk__in=[assoc.pk for assoc in deleted]).delete()
def on_delete_inherited(self, operation: int, target: list[int]) -> None: def on_delete_inherited(self, operation: int, target: list[int]) -> None:
''' Trigger cascade resolutions when Constituenta inheritance is deleted. ''' ''' Trigger cascade resolutions when Constituenta inheritance is deleted. '''

View File

@ -2,7 +2,7 @@
from typing import Optional from typing import Optional
from apps.library.models import LibraryItem, LibraryItemType from apps.library.models import LibraryItem, LibraryItemType
from apps.rsform.models import Association, Constituenta, CstType, RSFormCached from apps.rsform.models import Attribution, Constituenta, CstType, RSFormCached
from .OperationSchemaCached import CstSubstitution, OperationSchemaCached from .OperationSchemaCached import CstSubstitution, OperationSchemaCached
@ -82,20 +82,20 @@ class PropagationFacade:
OperationSchemaCached(host).before_delete_cst(item.pk, ids) OperationSchemaCached(host).before_delete_cst(item.pk, ids)
@staticmethod @staticmethod
def after_create_association(sourceID: int, associations: list[Association], def after_create_attribution(sourceID: int, associations: list[Attribution],
exclude: Optional[list[int]] = None) -> None: exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions when association is created. ''' ''' Trigger cascade resolutions when Attribution is created. '''
hosts = _get_oss_hosts(sourceID) hosts = _get_oss_hosts(sourceID)
for host in hosts: for host in hosts:
if exclude is None or host.pk not in exclude: if exclude is None or host.pk not in exclude:
OperationSchemaCached(host).after_create_association(sourceID, associations) OperationSchemaCached(host).after_create_attribution(sourceID, associations)
@staticmethod @staticmethod
def before_delete_association(sourceID: int, def before_delete_attribution(sourceID: int,
associations: list[Association], associations: list[Attribution],
exclude: Optional[list[int]] = None) -> None: exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions before association is deleted. ''' ''' Trigger cascade resolutions before Attribution is deleted. '''
hosts = _get_oss_hosts(sourceID) hosts = _get_oss_hosts(sourceID)
for host in hosts: for host in hosts:
if exclude is None or host.pk not in exclude: if exclude is None or host.pk not in exclude:
OperationSchemaCached(host).before_delete_association(sourceID, associations) OperationSchemaCached(host).before_delete_attribution(sourceID, associations)

View File

@ -12,9 +12,9 @@ class ConstituentaAdmin(admin.ModelAdmin):
search_fields = ['term_resolved', 'definition_resolved'] search_fields = ['term_resolved', 'definition_resolved']
@admin.register(models.Association) @admin.register(models.Attribution)
class AssociationAdmin(admin.ModelAdmin): class AssociationAdmin(admin.ModelAdmin):
''' Admin model: Association. ''' ''' Admin model: Association. '''
ordering = ['container__schema', 'container', 'associate'] ordering = ['container__schema', 'container', 'attribute']
list_display = ['container__schema__alias', 'container__alias', 'associate__alias'] list_display = ['container__schema__alias', 'container__alias', 'attribute__alias']
search_fields = ['container', 'associate'] search_fields = ['container', 'attribute']

View File

@ -0,0 +1,30 @@
# Generated by Django 5.2.4 on 2025-08-21 11:53
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rsform', '0005_alter_constituenta_cst_type_association'),
]
operations = [
migrations.CreateModel(
name='Attribution',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attribute', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='as_attribute', to='rsform.constituenta', verbose_name='Атрибутирующая конституента')),
('container', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='as_container', to='rsform.constituenta', verbose_name='Составная конституента')),
],
options={
'verbose_name': 'Атрибутирование конституент',
'verbose_name_plural': 'Атрибутирования конституент',
'unique_together': {('container', 'attribute')},
},
),
migrations.DeleteModel(
name='Association',
),
]

View File

@ -1,28 +0,0 @@
''' Models: Synthesis Inheritance. '''
from django.db.models import CASCADE, ForeignKey, Model
class Association(Model):
''' Association links nominal constituent to its content.'''
container = ForeignKey(
verbose_name='Составная конституента',
to='rsform.Constituenta',
on_delete=CASCADE,
related_name='as_container'
)
associate = ForeignKey(
verbose_name='Ассоциированная конституента',
to='rsform.Constituenta',
on_delete=CASCADE,
related_name='as_associate'
)
class Meta:
''' Model metadata. '''
verbose_name = 'Ассоциация конституент'
verbose_name_plural = 'Ассоциации конституент'
unique_together = [['container', 'associate']]
def __str__(self) -> str:
return f'{self.container} -> {self.associate}'

View File

@ -0,0 +1,28 @@
''' Models: Attribution of nominal constituents. '''
from django.db.models import CASCADE, ForeignKey, Model
class Attribution(Model):
''' Attribution links nominal constituent to its content.'''
container = ForeignKey(
verbose_name='Составная конституента',
to='rsform.Constituenta',
on_delete=CASCADE,
related_name='as_container'
)
attribute = ForeignKey(
verbose_name='Атрибутирующая конституента',
to='rsform.Constituenta',
on_delete=CASCADE,
related_name='as_attribute'
)
class Meta:
''' Model metadata. '''
verbose_name = 'Атрибутирование конституент'
verbose_name_plural = 'Атрибутирования конституент'
unique_together = [['container', 'attribute']]
def __str__(self) -> str:
return f'{self.container} -> {self.attribute}'

View File

@ -1,6 +1,6 @@
''' Django: Models. ''' ''' Django: Models. '''
from .Association import Association from .Attribution import Attribution
from .Constituenta import Constituenta, CstType, extract_globals, replace_entities, replace_globals from .Constituenta import Constituenta, CstType, extract_globals, replace_entities, replace_globals
from .OrderManager import OrderManager from .OrderManager import OrderManager
from .RSForm import DELETED_ALIAS, INSERT_LAST, RSForm from .RSForm import DELETED_ALIAS, INSERT_LAST, RSForm

View File

@ -12,8 +12,8 @@ from .basics import (
WordFormSerializer WordFormSerializer
) )
from .data_access import ( from .data_access import (
AssociationCreateSerializer, AttributionCreateSerializer,
AssociationDataSerializer, AttributionDataSerializer,
CrucialUpdateSerializer, CrucialUpdateSerializer,
CstCreateSerializer, CstCreateSerializer,
CstInfoSerializer, CstInfoSerializer,

View File

@ -17,23 +17,23 @@ from apps.oss.models import Inheritance
from shared import messages as msg from shared import messages as msg
from shared.serializers import StrictModelSerializer, StrictSerializer from shared.serializers import StrictModelSerializer, StrictSerializer
from ..models import Association, Constituenta, CstType, RSForm from ..models import Attribution, Constituenta, CstType, RSForm
from .basics import CstParseSerializer, InheritanceDataSerializer from .basics import CstParseSerializer, InheritanceDataSerializer
from .io_pyconcept import PyConceptAdapter from .io_pyconcept import PyConceptAdapter
class AssociationSerializer(StrictModelSerializer): class AttributionSerializer(StrictModelSerializer):
''' Serializer: Association relation. ''' ''' Serializer: Attribution relation. '''
class Meta: class Meta:
''' serializer metadata. ''' ''' serializer metadata. '''
model = Association model = Attribution
fields = ('container', 'associate') fields = ('container', 'attribute')
class AssociationDataSerializer(StrictSerializer): class AttributionDataSerializer(StrictSerializer):
''' Serializer: Association data. ''' ''' Serializer: Attribution data. '''
container = PKField(many=False, queryset=Constituenta.objects.all().only('schema_id')) container = PKField(many=False, queryset=Constituenta.objects.all().only('schema_id'))
associate = PKField(many=False, queryset=Constituenta.objects.all().only('schema_id')) attribute = PKField(many=False, queryset=Constituenta.objects.all().only('schema_id'))
def validate(self, attrs): def validate(self, attrs):
schema = cast(LibraryItem, self.context['schema']) schema = cast(LibraryItem, self.context['schema'])
@ -41,26 +41,26 @@ class AssociationDataSerializer(StrictSerializer):
raise serializers.ValidationError({ raise serializers.ValidationError({
'container': msg.constituentaNotInRSform(schema.title) 'container': msg.constituentaNotInRSform(schema.title)
}) })
if schema and attrs['associate'].schema_id != schema.id: if schema and attrs['attribute'].schema_id != schema.id:
raise serializers.ValidationError({ raise serializers.ValidationError({
'associate': msg.constituentaNotInRSform(schema.title) 'attribute': msg.constituentaNotInRSform(schema.title)
}) })
return attrs return attrs
class AssociationCreateSerializer(AssociationDataSerializer): class AttributionCreateSerializer(AttributionDataSerializer):
''' Serializer: Data for creating new association. ''' ''' Serializer: Data for creating new Attribution. '''
def validate(self, attrs): def validate(self, attrs):
attrs = super().validate(attrs) attrs = super().validate(attrs)
if attrs['container'].pk == attrs['associate'].pk: if attrs['container'].pk == attrs['attribute'].pk:
raise serializers.ValidationError({ raise serializers.ValidationError({
'container': msg.associationSelf() 'container': msg.associationSelf()
}) })
if Association.objects.filter(container=attrs['container'], associate=attrs['associate']).exists(): if Attribution.objects.filter(container=attrs['container'], attribute=attrs['attribute']).exists():
raise serializers.ValidationError({ raise serializers.ValidationError({
'associate': msg.associationAlreadyExists() 'attribute': msg.associationAlreadyExists()
}) })
return attrs return attrs
@ -187,8 +187,8 @@ class RSFormSerializer(StrictModelSerializer):
inheritance = serializers.ListField( inheritance = serializers.ListField(
child=InheritanceDataSerializer() child=InheritanceDataSerializer()
) )
association = serializers.ListField( attribution = serializers.ListField(
child=AssociationSerializer() child=AttributionSerializer()
) )
oss = serializers.ListField( oss = serializers.ListField(
child=LibraryItemReferenceSerializer() child=LibraryItemReferenceSerializer()
@ -220,7 +220,7 @@ class RSFormSerializer(StrictModelSerializer):
result['items'] = [] result['items'] = []
result['oss'] = [] result['oss'] = []
result['inheritance'] = [] result['inheritance'] = []
result['association'] = [] result['attribution'] = []
for cst in Constituenta.objects.filter(schema=instance).defer('order').order_by('order'): for cst in Constituenta.objects.filter(schema=instance).defer('order').order_by('order'):
result['items'].append(CstInfoSerializer(cst).data) result['items'].append(CstInfoSerializer(cst).data)
for oss in LibraryItem.objects.filter(operations__result=instance).only('alias'): for oss in LibraryItem.objects.filter(operations__result=instance).only('alias'):
@ -228,10 +228,10 @@ class RSFormSerializer(StrictModelSerializer):
'id': oss.pk, 'id': oss.pk,
'alias': oss.alias 'alias': oss.alias
}) })
for assoc in Association.objects.filter(container__schema=instance).only('container_id', 'associate_id'): for assoc in Attribution.objects.filter(container__schema=instance).only('container_id', 'attribute_id'):
result['association'].append({ result['attribution'].append({
'container': assoc.container_id, 'container': assoc.container_id,
'associate': assoc.associate_id 'attribute': assoc.attribute_id
}) })
return result return result
@ -302,22 +302,22 @@ class RSFormSerializer(StrictModelSerializer):
validated_data=loaded_item.validated_data validated_data=loaded_item.validated_data
) )
Association.objects.filter(container__schema=instance).delete() Attribution.objects.filter(container__schema=instance).delete()
associations_to_create: list[Association] = [] attributions_to_create: list[Attribution] = []
for assoc in data.get('association', []): for assoc in data.get('attribution', []):
old_container_id = assoc['container'] old_container_id = assoc['container']
old_associate_id = assoc['associate'] old_attribute_id = assoc['attribute']
container_id = id_map.get(old_container_id) container_id = id_map.get(old_container_id)
associate_id = id_map.get(old_associate_id) attribute_id = id_map.get(old_attribute_id)
if container_id and associate_id: if container_id and attribute_id:
associations_to_create.append( attributions_to_create.append(
Association( Attribution(
container_id=container_id, container_id=container_id,
associate_id=associate_id attribute_id=attribute_id
) )
) )
if associations_to_create: if attributions_to_create:
Association.objects.bulk_create(associations_to_create) Attribution.objects.bulk_create(attributions_to_create)
class RSFormParseSerializer(StrictModelSerializer): class RSFormParseSerializer(StrictModelSerializer):

View File

@ -1,5 +1,5 @@
''' Tests for Django Models. ''' ''' Tests for Django Models. '''
from .t_Association import * from .t_Attribution import *
from .t_Constituenta import * from .t_Constituenta import *
from .t_RSForm import * from .t_RSForm import *
from .t_RSFormCached import * from .t_RSFormCached import *

View File

@ -1,175 +0,0 @@
''' Testing models: Association. '''
from django.db.utils import IntegrityError
from django.test import TestCase
from apps.rsform.models import Association, Constituenta, CstType, RSForm
class TestAssociation(TestCase):
''' Testing Association model. '''
def setUp(self):
self.schema = RSForm.create(title='Test1')
# Create test constituents
self.container1 = Constituenta.objects.create(
alias='C1',
schema=self.schema.model,
order=1,
cst_type=CstType.NOMINAL
)
self.associate1 = Constituenta.objects.create(
alias='A1',
schema=self.schema.model,
order=2,
cst_type=CstType.BASE
)
def test_str(self):
''' Test string representation. '''
association = Association.objects.create(
container=self.container1,
associate=self.associate1
)
expected_str = f'{self.container1} -> {self.associate1}'
self.assertEqual(str(association), expected_str)
def test_create_association(self):
''' Test basic association creation. '''
association = Association.objects.create(
container=self.container1,
associate=self.associate1
)
self.assertEqual(association.container, self.container1)
self.assertEqual(association.associate, self.associate1)
self.assertIsNotNone(association.id)
def test_unique_constraint(self):
''' Test unique constraint on container and associate. '''
# Create first association
Association.objects.create(
container=self.container1,
associate=self.associate1
)
# Try to create duplicate association
with self.assertRaises(IntegrityError):
Association.objects.create(
container=self.container1,
associate=self.associate1
)
def test_container_not_null(self):
''' Test container field cannot be null. '''
with self.assertRaises(IntegrityError):
Association.objects.create(
container=None,
associate=self.associate1
)
def test_associate_not_null(self):
''' Test associate field cannot be null. '''
with self.assertRaises(IntegrityError):
Association.objects.create(
container=self.container1,
associate=None
)
def test_cascade_delete_container(self):
''' Test cascade delete when container is deleted. '''
association = Association.objects.create(
container=self.container1,
associate=self.associate1
)
association_id = association.id
# Delete the container
self.container1.delete()
# Association should be deleted due to CASCADE
with self.assertRaises(Association.DoesNotExist):
Association.objects.get(id=association_id)
def test_cascade_delete_associate(self):
''' Test cascade delete when associate is deleted. '''
association = Association.objects.create(
container=self.container1,
associate=self.associate1
)
association_id = association.id
# Delete the associate
self.associate1.delete()
# Association should be deleted due to CASCADE
with self.assertRaises(Association.DoesNotExist):
Association.objects.get(id=association_id)
def test_related_names(self):
''' Test related names for foreign key relationships. '''
association = Association.objects.create(
container=self.container1,
associate=self.associate1
)
# Test container related name
container_associations = self.container1.as_container.all()
self.assertEqual(len(container_associations), 1)
self.assertEqual(container_associations[0], association)
# Test associate related name
associate_associations = self.associate1.as_associate.all()
self.assertEqual(len(associate_associations), 1)
self.assertEqual(associate_associations[0], association)
def test_multiple_associations_same_container(self):
''' Test multiple associations with same container. '''
associate3 = Constituenta.objects.create(
alias='A3',
schema=self.schema.model,
order=3,
cst_type=CstType.BASE
)
association1 = Association.objects.create(
container=self.container1,
associate=self.associate1
)
association2 = Association.objects.create(
container=self.container1,
associate=associate3
)
container_associations = self.container1.as_container.all()
self.assertEqual(len(container_associations), 2)
self.assertIn(association1, container_associations)
self.assertIn(association2, container_associations)
def test_multiple_associations_same_associate(self):
''' Test multiple associations with same associate. '''
container3 = Constituenta.objects.create(
alias='C3',
schema=self.schema.model,
order=3,
cst_type=CstType.NOMINAL
)
association1 = Association.objects.create(
container=self.container1,
associate=self.associate1
)
association2 = Association.objects.create(
container=container3,
associate=self.associate1
)
associate_associations = self.associate1.as_associate.all()
self.assertEqual(len(associate_associations), 2)
self.assertIn(association1, associate_associations)
self.assertIn(association2, associate_associations)
def test_meta_unique_together(self):
''' Test Meta class unique_together constraint. '''
unique_together = Association._meta.unique_together
self.assertEqual(len(unique_together), 1)
self.assertIn(('container', 'associate'), unique_together)

View File

@ -0,0 +1,175 @@
''' Testing models: Association. '''
from django.db.utils import IntegrityError
from django.test import TestCase
from apps.rsform.models import Attribution, Constituenta, CstType, RSForm
class TestAttribution(TestCase):
''' Testing Attribution model. '''
def setUp(self):
self.schema = RSForm.create(title='Test1')
# Create test constituents
self.container1 = Constituenta.objects.create(
alias='C1',
schema=self.schema.model,
order=1,
cst_type=CstType.NOMINAL
)
self.attribute1 = Constituenta.objects.create(
alias='A1',
schema=self.schema.model,
order=2,
cst_type=CstType.BASE
)
def test_str(self):
''' Test string representation. '''
attribution = Attribution.objects.create(
container=self.container1,
attribute=self.attribute1
)
expected_str = f'{self.container1} -> {self.attribute1}'
self.assertEqual(str(attribution), expected_str)
def test_create_attribution(self):
''' Test basic Attribution creation. '''
attr = Attribution.objects.create(
container=self.container1,
attribute=self.attribute1
)
self.assertEqual(attr.container, self.container1)
self.assertEqual(attr.attribute, self.attribute1)
self.assertIsNotNone(attr.id)
def test_unique_constraint(self):
''' Test unique constraint on container and attribute. '''
# Create first Attribution
Attribution.objects.create(
container=self.container1,
attribute=self.attribute1
)
# Try to create duplicate Attribution
with self.assertRaises(IntegrityError):
Attribution.objects.create(
container=self.container1,
attribute=self.attribute1
)
def test_container_not_null(self):
''' Test container field cannot be null. '''
with self.assertRaises(IntegrityError):
Attribution.objects.create(
container=None,
attribute=self.attribute1
)
def test_attribute_not_null(self):
''' Test attribute field cannot be null. '''
with self.assertRaises(IntegrityError):
Attribution.objects.create(
container=self.container1,
attribute=None
)
def test_cascade_delete_container(self):
''' Test cascade delete when container is deleted. '''
attribution = Attribution.objects.create(
container=self.container1,
attribute=self.attribute1
)
association_id = attribution.id
# Delete the container
self.container1.delete()
# Attribution should be deleted due to CASCADE
with self.assertRaises(Attribution.DoesNotExist):
Attribution.objects.get(id=association_id)
def test_cascade_delete_attribute(self):
''' Test cascade delete when attribute is deleted. '''
attribution = Attribution.objects.create(
container=self.container1,
attribute=self.attribute1
)
association_id = attribution.id
# Delete the attribute
self.attribute1.delete()
# Attribution should be deleted due to CASCADE
with self.assertRaises(Attribution.DoesNotExist):
Attribution.objects.get(id=association_id)
def test_related_names(self):
''' Test related names for foreign key relationships. '''
attribution = Attribution.objects.create(
container=self.container1,
attribute=self.attribute1
)
# Test container related name
container_associations = self.container1.as_container.all()
self.assertEqual(len(container_associations), 1)
self.assertEqual(container_associations[0], attribution)
# Test attribute related name
attribute_associations = self.attribute1.as_attribute.all()
self.assertEqual(len(attribute_associations), 1)
self.assertEqual(attribute_associations[0], attribution)
def test_multiple_attributions_same_container(self):
''' Test multiple Attributions with same container. '''
attribute3 = Constituenta.objects.create(
alias='A3',
schema=self.schema.model,
order=3,
cst_type=CstType.BASE
)
attr1 = Attribution.objects.create(
container=self.container1,
attribute=self.attribute1
)
attr2 = Attribution.objects.create(
container=self.container1,
attribute=attribute3
)
container_associations = self.container1.as_container.all()
self.assertEqual(len(container_associations), 2)
self.assertIn(attr1, container_associations)
self.assertIn(attr2, container_associations)
def test_multiple_attributions_same_attribute(self):
''' Test multiple Attributions with same attribute. '''
container3 = Constituenta.objects.create(
alias='C3',
schema=self.schema.model,
order=3,
cst_type=CstType.NOMINAL
)
attr1 = Attribution.objects.create(
container=self.container1,
attribute=self.attribute1
)
attr2 = Attribution.objects.create(
container=container3,
attribute=self.attribute1
)
attribute_associations = self.attribute1.as_attribute.all()
self.assertEqual(len(attribute_associations), 2)
self.assertIn(attr1, attribute_associations)
self.assertIn(attr2, attribute_associations)
def test_meta_unique_together(self):
''' Test Meta class unique_together constraint. '''
unique_together = Attribution._meta.unique_together
self.assertEqual(len(unique_together), 1)
self.assertIn(('container', 'attribute'), unique_together)

View File

@ -1,5 +1,5 @@
''' Tests for REST API. ''' ''' Tests for REST API. '''
from .t_associations import * from .t_attribtuions import *
from .t_cctext import * from .t_cctext import *
from .t_constituenta import * from .t_constituenta import *
from .t_rsforms import * from .t_rsforms import *

View File

@ -1,4 +1,4 @@
''' Testing API: Association. ''' ''' Testing API: Attribution. '''
import io import io
import os import os
from zipfile import ZipFile from zipfile import ZipFile
@ -7,13 +7,13 @@ from cctext import ReferenceType
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, LibraryItem, LibraryItemType, LocationHead
from apps.rsform.models import Association, Constituenta, CstType, RSForm from apps.rsform.models import Attribution, Constituenta, CstType, RSForm
from shared.EndpointTester import EndpointTester, decl_endpoint from shared.EndpointTester import EndpointTester, decl_endpoint
from shared.testing_utils import response_contains from shared.testing_utils import response_contains
class TestAssociationsEndpoints(EndpointTester): class TestAttributionsEndpoints(EndpointTester):
''' Testing basic Association API. ''' ''' Testing basic Attribution API. '''
def setUp(self): def setUp(self):
super().setUp() super().setUp()
@ -28,73 +28,73 @@ class TestAssociationsEndpoints(EndpointTester):
self.invalid_id = self.n2.pk + 1337 self.invalid_id = self.n2.pk + 1337
@decl_endpoint('/api/rsforms/{item}/create-association', method='post') @decl_endpoint('/api/rsforms/{item}/create-attribution', method='post')
def test_create_association(self): def test_create_attribution(self):
self.executeBadData({}, item=self.owned_id) self.executeBadData({}, item=self.owned_id)
data = {'container': self.n1.pk, 'associate': self.invalid_id} data = {'container': self.n1.pk, 'attribute': self.invalid_id}
self.executeBadData(data, item=self.owned_id) self.executeBadData(data, item=self.owned_id)
data['associate'] = self.unowned_cst.pk data['attribute'] = self.unowned_cst.pk
self.executeBadData(data, item=self.owned_id) self.executeBadData(data, item=self.owned_id)
data['associate'] = data['container'] data['attribute'] = data['container']
self.executeBadData(data, item=self.owned_id) self.executeBadData(data, item=self.owned_id)
data = {'container': self.n1.pk, 'associate': self.x1.pk} data = {'container': self.n1.pk, 'attribute': self.x1.pk}
self.executeBadData(data, item=self.unowned_id) self.executeBadData(data, item=self.unowned_id)
response = self.executeCreated(data, item=self.owned_id) response = self.executeCreated(data, item=self.owned_id)
associations = response.data['association'] associations = response.data['attribution']
self.assertEqual(len(associations), 1) self.assertEqual(len(associations), 1)
self.assertEqual(associations[0]['container'], self.n1.pk) self.assertEqual(associations[0]['container'], self.n1.pk)
self.assertEqual(associations[0]['associate'], self.x1.pk) self.assertEqual(associations[0]['attribute'], self.x1.pk)
@decl_endpoint('/api/rsforms/{item}/create-association', method='post') @decl_endpoint('/api/rsforms/{item}/create-attribution', method='post')
def test_create_association_duplicate(self): def test_create_attribution_duplicate(self):
data = {'container': self.n1.pk, 'associate': self.x1.pk} data = {'container': self.n1.pk, 'attribute': self.x1.pk}
self.executeCreated(data, item=self.owned_id) self.executeCreated(data, item=self.owned_id)
self.executeBadData(data, item=self.owned_id) self.executeBadData(data, item=self.owned_id)
@decl_endpoint('/api/rsforms/{item}/delete-association', method='patch') @decl_endpoint('/api/rsforms/{item}/delete-attribution', method='patch')
def test_delete_association(self): def test_delete_attribution(self):
data = {'container': self.n1.pk, 'associate': self.x1.pk} data = {'container': self.n1.pk, 'attribute': self.x1.pk}
self.executeForbidden(data, item=self.unowned_id) self.executeForbidden(data, item=self.unowned_id)
self.executeBadData(data, item=self.owned_id) self.executeBadData(data, item=self.owned_id)
Association.objects.create( Attribution.objects.create(
container=self.n1, container=self.n1,
associate=self.x1 attribute=self.x1
) )
self.executeForbidden(data, item=self.unowned_id) self.executeForbidden(data, item=self.unowned_id)
response = self.executeOK(data, item=self.owned_id) response = self.executeOK(data, item=self.owned_id)
associations = response.data['association'] attributions = response.data['attribution']
self.assertEqual(len(associations), 0) self.assertEqual(len(attributions), 0)
@decl_endpoint('/api/rsforms/{item}/clear-associations', method='patch') @decl_endpoint('/api/rsforms/{item}/clear-attributions', method='patch')
def test_clear_associations(self): def test_clear_attributions(self):
data = {'target': self.n1.pk} data = {'target': self.n1.pk}
self.executeForbidden(data, item=self.unowned_id) self.executeForbidden(data, item=self.unowned_id)
self.executeNotFound(data, item=self.invalid_id) self.executeNotFound(data, item=self.invalid_id)
self.executeOK(data, item=self.owned_id) self.executeOK(data, item=self.owned_id)
Association.objects.create( Attribution.objects.create(
container=self.n1, container=self.n1,
associate=self.x1 attribute=self.x1
) )
Association.objects.create( Attribution.objects.create(
container=self.n1, container=self.n1,
associate=self.n2 attribute=self.n2
) )
Association.objects.create( Attribution.objects.create(
container=self.n2, container=self.n2,
associate=self.n1 attribute=self.n1
) )
response = self.executeOK(data, item=self.owned_id) response = self.executeOK(data, item=self.owned_id)
associations = response.data['association'] associations = response.data['attribution']
self.assertEqual(len(associations), 1) self.assertEqual(len(associations), 1)
self.assertEqual(associations[0]['container'], self.n2.pk) self.assertEqual(associations[0]['container'], self.n2.pk)
self.assertEqual(associations[0]['associate'], self.n1.pk) self.assertEqual(associations[0]['attribute'], self.n1.pk)

View File

@ -49,9 +49,9 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
'restore_order', 'restore_order',
'reset_aliases', 'reset_aliases',
'produce_structure', 'produce_structure',
'add_association', 'add_attribution',
'delete_association', 'delete_attribution',
'clear_associations' 'clear_attributions'
]: ]:
permission_list = [permissions.ItemEditor] permission_list = [permissions.ItemEditor]
elif self.action in [ elif self.action in [
@ -285,9 +285,9 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
) )
@extend_schema( @extend_schema(
summary='create Association', summary='create Attribution',
tags=['Constituenta'], tags=['Constituenta'],
request=s.AssociationCreateSerializer, request=s.AttributionCreateSerializer,
responses={ responses={
c.HTTP_201_CREATED: s.RSFormParseSerializer, c.HTTP_201_CREATED: s.RSFormParseSerializer,
c.HTTP_400_BAD_REQUEST: None, c.HTTP_400_BAD_REQUEST: None,
@ -295,21 +295,21 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
c.HTTP_404_NOT_FOUND: None c.HTTP_404_NOT_FOUND: None
} }
) )
@action(detail=True, methods=['post'], url_path='create-association') @action(detail=True, methods=['post'], url_path='create-attribution')
def create_association(self, request: Request, pk) -> HttpResponse: def create_attribution(self, request: Request, pk) -> HttpResponse:
''' Create Association. ''' ''' Create Attribution. '''
item = self._get_item() item = self._get_item()
serializer = s.AssociationCreateSerializer(data=request.data, context={'schema': item}) serializer = s.AttributionCreateSerializer(data=request.data, context={'schema': item})
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
container = serializer.validated_data['container'] container = serializer.validated_data['container']
associate = serializer.validated_data['associate'] attribute = serializer.validated_data['attribute']
with transaction.atomic(): with transaction.atomic():
new_association = m.Association.objects.create( new_association = m.Attribution.objects.create(
container=container, container=container,
associate=associate attribute=attribute
) )
PropagationFacade.after_create_association(item.pk, [new_association]) PropagationFacade.after_create_attribution(item.pk, [new_association])
item.save(update_fields=['time_update']) item.save(update_fields=['time_update'])
return Response( return Response(
@ -320,7 +320,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
@extend_schema( @extend_schema(
summary='delete Association', summary='delete Association',
tags=['RSForm'], tags=['RSForm'],
request=s.AssociationDataSerializer, request=s.AttributionDataSerializer,
responses={ responses={
c.HTTP_200_OK: s.RSFormParseSerializer, c.HTTP_200_OK: s.RSFormParseSerializer,
c.HTTP_400_BAD_REQUEST: None, c.HTTP_400_BAD_REQUEST: None,
@ -328,25 +328,25 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
c.HTTP_404_NOT_FOUND: None c.HTTP_404_NOT_FOUND: None
} }
) )
@action(detail=True, methods=['patch'], url_path='delete-association') @action(detail=True, methods=['patch'], url_path='delete-attribution')
def delete_association(self, request: Request, pk) -> HttpResponse: def delete_attribution(self, request: Request, pk) -> HttpResponse:
''' Endpoint: Delete Association. ''' ''' Endpoint: Delete Attribution. '''
item = self._get_item() item = self._get_item()
serializer = s.AssociationDataSerializer(data=request.data, context={'schema': item}) serializer = s.AttributionDataSerializer(data=request.data, context={'schema': item})
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
with transaction.atomic(): with transaction.atomic():
target = list(m.Association.objects.filter( target = list(m.Attribution.objects.filter(
container=serializer.validated_data['container'], container=serializer.validated_data['container'],
associate=serializer.validated_data['associate'] attribute=serializer.validated_data['attribute']
)) ))
if not target: if not target:
raise ValidationError({ raise ValidationError({
'container': msg.invalidAssociation() 'container': msg.invalidAssociation()
}) })
PropagationFacade.before_delete_association(item.pk, target) PropagationFacade.before_delete_attribution(item.pk, target)
m.Association.objects.filter(pk__in=[assoc.pk for assoc in target]).delete() m.Attribution.objects.filter(pk__in=[assoc.pk for assoc in target]).delete()
item.save(update_fields=['time_update']) item.save(update_fields=['time_update'])
return Response( return Response(
@ -355,7 +355,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
) )
@extend_schema( @extend_schema(
summary='delete all associations for target constituenta', summary='delete all Attributions for target constituenta',
tags=['RSForm'], tags=['RSForm'],
request=s.CstTargetSerializer, request=s.CstTargetSerializer,
responses={ responses={
@ -365,18 +365,18 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
c.HTTP_404_NOT_FOUND: None c.HTTP_404_NOT_FOUND: None
} }
) )
@action(detail=True, methods=['patch'], url_path='clear-associations') @action(detail=True, methods=['patch'], url_path='clear-attributions')
def clear_associations(self, request: Request, pk) -> HttpResponse: def clear_attributions(self, request: Request, pk) -> HttpResponse:
''' Endpoint: Delete Associations for target Constituenta. ''' ''' Endpoint: Delete Associations for target Constituenta. '''
item = self._get_item() item = self._get_item()
serializer = s.CstTargetSerializer(data=request.data, context={'schema': item}) serializer = s.CstTargetSerializer(data=request.data, context={'schema': item})
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
with transaction.atomic(): with transaction.atomic():
target = list(m.Association.objects.filter(container=serializer.validated_data['target'])) target = list(m.Attribution.objects.filter(container=serializer.validated_data['target']))
if target: if target:
PropagationFacade.before_delete_association(item.pk, target) PropagationFacade.before_delete_attribution(item.pk, target)
m.Association.objects.filter(pk__in=[assoc.pk for assoc in target]).delete() m.Attribution.objects.filter(pk__in=[assoc.pk for assoc in target]).delete()
item.save(update_fields=['time_update']) item.save(update_fields=['time_update'])
return Response( return Response(

View File

@ -6,7 +6,7 @@ export function HelpConceptRelations() {
<div className='text-justify'> <div className='text-justify'>
<h1>Связи между конституентами</h1> <h1>Связи между конституентами</h1>
<p> <p>
Наиболее общей связью между конституентами является ассоциация, устанавливаемая между номеноидом и относимыми к Наиболее общей связью между конституентами является ассоциация, устанавливаемая между номиноидом и относимыми к
нему другими конституентами. Такая связь задается до установления точных определений и применяется для нему другими конституентами. Такая связь задается до установления точных определений и применяется для
предварительной фиксации групп связанных конституент. предварительной фиксации групп связанных конституент.
</p> </p>

View File

@ -121,7 +121,7 @@ export function HelpThesaurus() {
<b>Типы конституент</b> <b>Типы конституент</b>
<li> <li>
<IconCstNominal size='1rem' className='inline-icon' /> <IconCstNominal size='1rem' className='inline-icon' />
{'\u2009'}Номеноид (N#) предметная сущность, не имеющая четкого определения, используемая для ассоциативной {'\u2009'}Номиноид (N#) предметная сущность, не имеющая четкого определения, используемая для ассоциативной
группировки конституент и предварительной фиксации содержательных отношений. группировки конституент и предварительной фиксации содержательных отношений.
</li> </li>
<li> <li>

View File

@ -43,7 +43,7 @@ export function describeSubstitutionError(error: RO<ISubstitutionErrorDescriptio
case SubstitutionErrorType.invalidConstant: case SubstitutionErrorType.invalidConstant:
return `Ошибка ${error.params[0]} -> ${error.params[1]}: подстановка константного множества возможна только вместо другого константного`; return `Ошибка ${error.params[0]} -> ${error.params[1]}: подстановка константного множества возможна только вместо другого константного`;
case SubstitutionErrorType.invalidNominal: case SubstitutionErrorType.invalidNominal:
return `Ошибка ${error.params[0]} -> ${error.params[1]}: подстановка номеноида возможна только вместо другого номеноида`; return `Ошибка ${error.params[0]} -> ${error.params[1]}: подстановка номиноида возможна только вместо другого номиноида`;
case SubstitutionErrorType.invalidClasses: case SubstitutionErrorType.invalidClasses:
return `Ошибка ${error.params[0]} -> ${error.params[1]}: классы конституент не совпадают`; return `Ошибка ${error.params[0]} -> ${error.params[1]}: классы конституент не совпадают`;
case SubstitutionErrorType.typificationCycle: case SubstitutionErrorType.typificationCycle:

View File

@ -5,8 +5,8 @@ import { DELAYS, KEYS } from '@/backend/configuration';
import { infoMsg } from '@/utils/labels'; import { infoMsg } from '@/utils/labels';
import { import {
type IAssociation, type IAttribution,
type IAssociationTargetDTO, type IAttributionTargetDTO,
type ICheckConstituentaDTO, type ICheckConstituentaDTO,
type IConstituentaCreatedResponse, type IConstituentaCreatedResponse,
type IConstituentaList, type IConstituentaList,
@ -154,28 +154,28 @@ export const rsformsApi = {
request: { data: data } request: { data: data }
}), }),
createAssociation: ({ itemID, data }: { itemID: number; data: IAssociation }) => createAttribution: ({ itemID, data }: { itemID: number; data: IAttribution }) =>
axiosPost<IAssociation, IRSFormDTO>({ axiosPost<IAttribution, IRSFormDTO>({
schema: schemaRSForm, schema: schemaRSForm,
endpoint: `/api/rsforms/${itemID}/create-association`, endpoint: `/api/rsforms/${itemID}/create-attribution`,
request: { request: {
data: data, data: data,
successMessage: infoMsg.changesSaved successMessage: infoMsg.changesSaved
} }
}), }),
deleteAssociation: ({ itemID, data }: { itemID: number; data: IAssociation }) => deleteAttribution: ({ itemID, data }: { itemID: number; data: IAttribution }) =>
axiosPatch<IAssociation, IRSFormDTO>({ axiosPatch<IAttribution, IRSFormDTO>({
schema: schemaRSForm, schema: schemaRSForm,
endpoint: `/api/rsforms/${itemID}/delete-association`, endpoint: `/api/rsforms/${itemID}/delete-attribution`,
request: { request: {
data: data, data: data,
successMessage: infoMsg.changesSaved successMessage: infoMsg.changesSaved
} }
}), }),
clearAssociations: ({ itemID, data }: { itemID: number; data: IAssociationTargetDTO }) => clearAttributions: ({ itemID, data }: { itemID: number; data: IAttributionTargetDTO }) =>
axiosPatch<IAssociationTargetDTO, IRSFormDTO>({ axiosPatch<IAttributionTargetDTO, IRSFormDTO>({
schema: schemaRSForm, schema: schemaRSForm,
endpoint: `/api/rsforms/${itemID}/clear-associations`, endpoint: `/api/rsforms/${itemID}/clear-attributions`,
request: { request: {
data: data, data: data,
successMessage: infoMsg.changesSaved successMessage: infoMsg.changesSaved

View File

@ -41,7 +41,7 @@ export class RSFormLoader {
result.graph = this.graph; result.graph = this.graph;
result.cstByAlias = this.cstByAlias; result.cstByAlias = this.cstByAlias;
result.cstByID = this.cstByID; result.cstByID = this.cstByID;
result.association_graph = this.association_graph; result.attribution_graph = this.association_graph;
result.full_graph = this.full_graph; result.full_graph = this.full_graph;
return result; return result;
} }
@ -90,7 +90,7 @@ export class RSFormLoader {
cst.is_template = inferTemplate(cst.definition_formal); cst.is_template = inferTemplate(cst.definition_formal);
cst.cst_class = inferClass(cst.cst_type, cst.is_template); cst.cst_class = inferClass(cst.cst_type, cst.is_template);
cst.spawn = []; cst.spawn = [];
cst.associations = []; cst.attributes = [];
cst.spawn_alias = []; cst.spawn_alias = [];
cst.parent_schema = schemaByCst.get(cst.id); cst.parent_schema = schemaByCst.get(cst.id);
cst.parent_schema_index = cst.parent_schema ? parents.indexOf(cst.parent_schema) + 1 : 0; cst.parent_schema_index = cst.parent_schema ? parents.indexOf(cst.parent_schema) + 1 : 0;
@ -110,11 +110,11 @@ export class RSFormLoader {
parent.spawn_alias.push(cst.alias); parent.spawn_alias.push(cst.alias);
} }
}); });
this.schema.association.forEach(assoc => { this.schema.attribution.forEach(assoc => {
const container = this.cstByID.get(assoc.container)!; const container = this.cstByID.get(assoc.container)!;
container.associations.push(assoc.associate); container.attributes.push(assoc.attribute);
this.full_graph.addEdge(container.id, assoc.associate); this.full_graph.addEdge(container.id, assoc.attribute);
this.association_graph.addEdge(container.id, assoc.associate); this.association_graph.addEdge(container.id, assoc.attribute);
}); });
} }

View File

@ -94,11 +94,11 @@ export interface ICheckConstituentaDTO {
/** Represents data, used in merging multiple {@link IConstituenta}. */ /** Represents data, used in merging multiple {@link IConstituenta}. */
export type ISubstitutionsDTO = z.infer<typeof schemaSubstitutions>; export type ISubstitutionsDTO = z.infer<typeof schemaSubstitutions>;
/** Represents data for creating or deleting an association. */ /** Represents data for creating or deleting an Attribution. */
export type IAssociation = z.infer<typeof schemaAssociation>; export type IAttribution = z.infer<typeof schemaAttribution>;
/** Represents data for clearing all associations for a target constituenta. */ /** Represents data for clearing all associations for a target constituenta. */
export type IAssociationTargetDTO = z.infer<typeof schemaAssociationTarget>; export type IAttributionTargetDTO = z.infer<typeof schemaAttributionTarget>;
/** Represents Constituenta list. */ /** Represents Constituenta list. */
export interface IConstituentaList { export interface IConstituentaList {
@ -308,9 +308,9 @@ export const schemaConstituenta = schemaConstituentaBasics.extend({
.optional() .optional()
}); });
export const schemaAssociation = z.strictObject({ export const schemaAttribution = z.strictObject({
container: z.number(), container: z.number(),
associate: z.number() attribute: z.number()
}); });
export const schemaRSForm = schemaLibraryItem.extend({ export const schemaRSForm = schemaLibraryItem.extend({
@ -320,7 +320,7 @@ export const schemaRSForm = schemaLibraryItem.extend({
versions: z.array(schemaVersionInfo), versions: z.array(schemaVersionInfo),
items: z.array(schemaConstituenta), items: z.array(schemaConstituenta),
association: z.array(schemaAssociation), attribution: z.array(schemaAttribution),
inheritance: z.array( inheritance: z.array(
z.strictObject({ z.strictObject({
child: z.number(), child: z.number(),
@ -397,7 +397,7 @@ export const schemaSubstitutions = z.strictObject({
substitutions: z.array(schemaSubstituteConstituents).min(1, { message: errorMsg.emptySubstitutions }) substitutions: z.array(schemaSubstituteConstituents).min(1, { message: errorMsg.emptySubstitutions })
}); });
export const schemaAssociationTarget = z.strictObject({ export const schemaAttributionTarget = z.strictObject({
target: z.number() target: z.number()
}); });

View File

@ -5,14 +5,14 @@ import { useUpdateTimestamp } from '@/features/library/backend/use-update-timest
import { KEYS } from '@/backend/configuration'; import { KEYS } from '@/backend/configuration';
import { rsformsApi } from './api'; import { rsformsApi } from './api';
import { type IAssociationTargetDTO } from './types'; import { type IAttributionTargetDTO } from './types';
export const useClearAssociations = () => { export const useClearAttributions = () => {
const client = useQueryClient(); const client = useQueryClient();
const { updateTimestamp } = useUpdateTimestamp(); const { updateTimestamp } = useUpdateTimestamp();
const mutation = useMutation({ const mutation = useMutation({
mutationKey: [KEYS.global_mutation, rsformsApi.baseKey, 'clear-associations'], mutationKey: [KEYS.global_mutation, rsformsApi.baseKey, 'clear-attributions'],
mutationFn: rsformsApi.clearAssociations, mutationFn: rsformsApi.clearAttributions,
onSuccess: async data => { onSuccess: async data => {
updateTimestamp(data.id, data.time_update); updateTimestamp(data.id, data.time_update);
client.setQueryData(rsformsApi.getRSFormQueryOptions({ itemID: data.id }).queryKey, data); client.setQueryData(rsformsApi.getRSFormQueryOptions({ itemID: data.id }).queryKey, data);
@ -24,6 +24,6 @@ export const useClearAssociations = () => {
onError: () => client.invalidateQueries() onError: () => client.invalidateQueries()
}); });
return { return {
clearAssociations: (data: { itemID: number; data: IAssociationTargetDTO }) => mutation.mutateAsync(data) clearAttributions: (data: { itemID: number; data: IAttributionTargetDTO }) => mutation.mutateAsync(data)
}; };
}; };

View File

@ -5,14 +5,14 @@ import { useUpdateTimestamp } from '@/features/library/backend/use-update-timest
import { KEYS } from '@/backend/configuration'; import { KEYS } from '@/backend/configuration';
import { rsformsApi } from './api'; import { rsformsApi } from './api';
import { type IAssociation } from './types'; import { type IAttribution } from './types';
export const useCreateAssociation = () => { export const useCreateAttribution = () => {
const client = useQueryClient(); const client = useQueryClient();
const { updateTimestamp } = useUpdateTimestamp(); const { updateTimestamp } = useUpdateTimestamp();
const mutation = useMutation({ const mutation = useMutation({
mutationKey: [KEYS.global_mutation, rsformsApi.baseKey, 'create-association'], mutationKey: [KEYS.global_mutation, rsformsApi.baseKey, 'create-attribution'],
mutationFn: rsformsApi.createAssociation, mutationFn: rsformsApi.createAttribution,
onSuccess: async data => { onSuccess: async data => {
updateTimestamp(data.id, data.time_update); updateTimestamp(data.id, data.time_update);
client.setQueryData(rsformsApi.getRSFormQueryOptions({ itemID: data.id }).queryKey, data); client.setQueryData(rsformsApi.getRSFormQueryOptions({ itemID: data.id }).queryKey, data);
@ -24,6 +24,6 @@ export const useCreateAssociation = () => {
onError: () => client.invalidateQueries() onError: () => client.invalidateQueries()
}); });
return { return {
createAssociation: (data: { itemID: number; data: IAssociation }) => mutation.mutateAsync(data) createAttribution: (data: { itemID: number; data: IAttribution }) => mutation.mutateAsync(data)
}; };
}; };

View File

@ -5,14 +5,14 @@ import { useUpdateTimestamp } from '@/features/library/backend/use-update-timest
import { KEYS } from '@/backend/configuration'; import { KEYS } from '@/backend/configuration';
import { rsformsApi } from './api'; import { rsformsApi } from './api';
import { type IAssociation } from './types'; import { type IAttribution } from './types';
export const useDeleteAssociation = () => { export const useDeleteAttribution = () => {
const client = useQueryClient(); const client = useQueryClient();
const { updateTimestamp } = useUpdateTimestamp(); const { updateTimestamp } = useUpdateTimestamp();
const mutation = useMutation({ const mutation = useMutation({
mutationKey: [KEYS.global_mutation, rsformsApi.baseKey, 'delete-association'], mutationKey: [KEYS.global_mutation, rsformsApi.baseKey, 'delete-attribution'],
mutationFn: rsformsApi.deleteAssociation, mutationFn: rsformsApi.deleteAttribution,
onSuccess: async data => { onSuccess: async data => {
updateTimestamp(data.id, data.time_update); updateTimestamp(data.id, data.time_update);
client.setQueryData(rsformsApi.getRSFormQueryOptions({ itemID: data.id }).queryKey, data); client.setQueryData(rsformsApi.getRSFormQueryOptions({ itemID: data.id }).queryKey, data);
@ -24,6 +24,6 @@ export const useDeleteAssociation = () => {
onError: () => client.invalidateQueries() onError: () => client.invalidateQueries()
}); });
return { return {
deleteAssociation: (data: { itemID: number; data: IAssociation }) => mutation.mutateAsync(data) deleteAttribution: (data: { itemID: number; data: IAttribution }) => mutation.mutateAsync(data)
}; };
}; };

View File

@ -259,7 +259,7 @@ export function colorGraphEdge(edgeType: GraphType): string {
return APP_COLORS.bgGreen; return APP_COLORS.bgGreen;
case 'definition': case 'definition':
return APP_COLORS.border; return APP_COLORS.border;
case 'association': case 'attribution':
return APP_COLORS.bgPurple; return APP_COLORS.bgPurple;
} }
} }

View File

@ -68,7 +68,7 @@ export function RSFormStats({ className, stats }: RSFormStatsProps) {
/> />
<ValueStats <ValueStats
id='count_nominal' id='count_nominal'
title='Номеноиды' title='Номиноиды'
icon={<IconCstNominal size='1.25rem' className={stats.count_nominal > 0 ? 'text-destructive' : undefined} />} icon={<IconCstNominal size='1.25rem' className={stats.count_nominal > 0 ? 'text-destructive' : undefined} />}
value={stats.count_nominal} value={stats.count_nominal}
/> />

View File

@ -8,9 +8,9 @@ import { MiniButton } from '@/components/control';
import { Label, TextArea, TextInput } from '@/components/input'; import { Label, TextArea, TextInput } from '@/components/input';
import { CstType, type IUpdateConstituentaDTO } from '../../backend/types'; import { CstType, type IUpdateConstituentaDTO } from '../../backend/types';
import { useClearAssociations } from '../../backend/use-clear-associations'; import { useClearAttributions } from '../../backend/use-clear-attributions';
import { useCreateAssociation } from '../../backend/use-create-association'; import { useCreateAttribution } from '../../backend/use-create-attribution';
import { useDeleteAssociation } from '../../backend/use-delete-association'; import { useDeleteAttribution } from '../../backend/use-delete-attribution';
import { IconCrucialValue } from '../../components/icon-crucial-value'; import { IconCrucialValue } from '../../components/icon-crucial-value';
import { RSInput } from '../../components/rs-input'; import { RSInput } from '../../components/rs-input';
import { SelectCstType } from '../../components/select-cst-type'; import { SelectCstType } from '../../components/select-cst-type';
@ -25,9 +25,9 @@ interface FormEditCstProps {
} }
export function FormEditCst({ target, schema }: FormEditCstProps) { export function FormEditCst({ target, schema }: FormEditCstProps) {
const { createAssociation } = useCreateAssociation(); const { createAttribution } = useCreateAttribution();
const { deleteAssociation } = useDeleteAssociation(); const { deleteAttribution } = useDeleteAttribution();
const { clearAssociations } = useClearAssociations(); const { clearAttributions } = useClearAttributions();
const { const {
setValue, setValue,
@ -44,7 +44,7 @@ export function FormEditCst({ target, schema }: FormEditCstProps) {
const isBasic = isBasicConcept(cst_type) || cst_type === CstType.NOMINAL; const isBasic = isBasicConcept(cst_type) || cst_type === CstType.NOMINAL;
const isElementary = isBaseSet(cst_type); const isElementary = isBaseSet(cst_type);
const showConvention = !!convention || forceComment || isBasic; const showConvention = !!convention || forceComment || isBasic;
const associations = target.associations.map(id => schema.cstByID.get(id)!); const attributions = target.attributes.map(id => schema.cstByID.get(id)!);
function handleTypeChange(newValue: CstType) { function handleTypeChange(newValue: CstType) {
setValue('item_data.cst_type', newValue); setValue('item_data.cst_type', newValue);
@ -56,28 +56,28 @@ export function FormEditCst({ target, schema }: FormEditCstProps) {
setValue('item_data.crucial', !crucial); setValue('item_data.crucial', !crucial);
} }
function handleAddAssociation(item: IConstituenta) { function handleAddAttribution(item: IConstituenta) {
void createAssociation({ void createAttribution({
itemID: schema.id, itemID: schema.id,
data: { data: {
container: target.id, container: target.id,
associate: item.id attribute: item.id
} }
}); });
} }
function handleRemoveAssociation(item: IConstituenta) { function handleRemoveAttribution(item: IConstituenta) {
void deleteAssociation({ void deleteAttribution({
itemID: schema.id, itemID: schema.id,
data: { data: {
container: target.id, container: target.id,
associate: item.id attribute: item.id
} }
}); });
} }
function handleClearAssociations() { function handleClearAttributions() {
void clearAssociations({ void clearAttributions({
itemID: schema.id, itemID: schema.id,
data: { data: {
target: target.id target: target.id
@ -121,15 +121,15 @@ export function FormEditCst({ target, schema }: FormEditCstProps) {
error={errors.item_data?.term_raw} error={errors.item_data?.term_raw}
/> />
{target.cst_type === CstType.NOMINAL || target.associations.length > 0 ? ( {target.cst_type === CstType.NOMINAL || target.attributes.length > 0 ? (
<div className='flex flex-col gap-1'> <div className='flex flex-col gap-1'>
<Label text='Ассоциируемые конституенты' /> <Label text='Атрибутирующие конституенты' />
<SelectMultiConstituenta <SelectMultiConstituenta
items={schema.items.filter(item => item.id !== target.id)} items={schema.items.filter(item => item.id !== target.id)}
value={associations} value={attributions}
onAdd={handleAddAssociation} onAdd={handleAddAttribution}
onClear={handleClearAssociations} onClear={handleClearAttributions}
onRemove={handleRemoveAssociation} onRemove={handleRemoveAttribution}
placeholder={'Выберите конституенты'} placeholder={'Выберите конституенты'}
/> />
</div> </div>

View File

@ -18,7 +18,7 @@ import { type GraphColoring, type GraphType } from './stores/term-graph';
// --- Records for label/describe functions --- // --- Records for label/describe functions ---
const labelCstTypeRecord: Record<CstType, string> = { const labelCstTypeRecord: Record<CstType, string> = {
[CstType.NOMINAL]: 'Номеноид', [CstType.NOMINAL]: 'Номиноид',
[CstType.BASE]: 'Базисное множество', [CstType.BASE]: 'Базисное множество',
[CstType.CONSTANT]: 'Константное множество', [CstType.CONSTANT]: 'Константное множество',
[CstType.STRUCTURED]: 'Родовая структура', [CstType.STRUCTURED]: 'Родовая структура',
@ -60,7 +60,7 @@ const labelColoringRecord: Record<GraphColoring, string> = {
const labelGraphTypeRecord: Record<GraphType, string> = { const labelGraphTypeRecord: Record<GraphType, string> = {
full: 'Связи: Все', full: 'Связи: Все',
definition: 'Связи: Определения', definition: 'Связи: Определения',
association: 'Связи: Ассоциации' attribution: 'Связи: Атрибутирование'
}; };
const labelCstMatchModeRecord: Record<CstMatchMode, string> = { const labelCstMatchModeRecord: Record<CstMatchMode, string> = {

View File

@ -59,15 +59,15 @@ export function applyLayout(nodes: Node<TGNodeState>[], edges: Edge[], subLabels
export function inferEdgeType(schema: IRSForm, source: number, target: number): GraphType | null { export function inferEdgeType(schema: IRSForm, source: number, target: number): GraphType | null {
const isDefinition = schema.graph.hasEdge(source, target); const isDefinition = schema.graph.hasEdge(source, target);
const isAssociation = schema.association_graph.hasEdge(source, target); const isAttribution = schema.attribution_graph.hasEdge(source, target);
if (!isDefinition && !isAssociation) { if (!isDefinition && !isAttribution) {
return null; return null;
} else if (isDefinition && isAssociation) { } else if (isDefinition && isAttribution) {
return 'full'; return 'full';
} else if (isDefinition) { } else if (isDefinition) {
return 'definition'; return 'definition';
} else { } else {
return 'association'; return 'attribution';
} }
} }
@ -75,8 +75,8 @@ export function produceFilteredGraph(schema: IRSForm, params: GraphFilterParams,
const filtered = const filtered =
params.graphType === 'full' params.graphType === 'full'
? schema.full_graph.clone() ? schema.full_graph.clone()
: params.graphType === 'association' : params.graphType === 'attribution'
? schema.association_graph.clone() ? schema.attribution_graph.clone()
: schema.graph.clone(); : schema.graph.clone();
const allowedTypes: CstType[] = (() => { const allowedTypes: CstType[] = (() => {
const result: CstType[] = []; const result: CstType[] = [];

View File

@ -11,7 +11,7 @@ import {
import { type Graph } from '@/models/graph'; import { type Graph } from '@/models/graph';
import { CstType, type IAssociation, type ParsingStatus, type ValueClass } from '../backend/types'; import { CstType, type IAttribution, type ParsingStatus, type ValueClass } from '../backend/types';
import { type IArgumentInfo } from './rslang'; import { type IArgumentInfo } from './rslang';
@ -58,7 +58,7 @@ export interface IConstituenta {
term_raw: string; term_raw: string;
term_resolved: string; term_resolved: string;
term_forms: TermForm[]; term_forms: TermForm[];
associations: number[]; attributes: number[];
parse?: { parse?: {
status: ParsingStatus; status: ParsingStatus;
@ -142,12 +142,12 @@ export interface IRSForm extends ILibraryItemData {
items: IConstituenta[]; items: IConstituenta[];
inheritance: IInheritanceInfo[]; inheritance: IInheritanceInfo[];
association: IAssociation[]; attribution: IAttribution[];
oss: ILibraryItemReference[]; oss: ILibraryItemReference[];
stats: IRSFormStats; stats: IRSFormStats;
graph: Graph; graph: Graph;
association_graph: Graph; attribution_graph: Graph;
full_graph: Graph; full_graph: Graph;
cstByAlias: Map<string, IConstituenta>; cstByAlias: Map<string, IConstituenta>;
cstByID: Map<number, IConstituenta>; cstByID: Map<number, IConstituenta>;

View File

@ -24,9 +24,9 @@ import {
ParsingStatus, ParsingStatus,
schemaUpdateConstituenta schemaUpdateConstituenta
} from '../../../backend/types'; } from '../../../backend/types';
import { useClearAssociations } from '../../../backend/use-clear-associations'; import { useClearAttributions } from '../../../backend/use-clear-attributions';
import { useCreateAssociation } from '../../../backend/use-create-association'; import { useCreateAttribution } from '../../../backend/use-create-attribution';
import { useDeleteAssociation } from '../../../backend/use-delete-association'; import { useDeleteAttribution } from '../../../backend/use-delete-attribution';
import { useMutatingRSForm } from '../../../backend/use-mutating-rsform'; import { useMutatingRSForm } from '../../../backend/use-mutating-rsform';
import { useUpdateConstituenta } from '../../../backend/use-update-constituenta'; import { useUpdateConstituenta } from '../../../backend/use-update-constituenta';
import { useUpdateCrucial } from '../../../backend/use-update-crucial'; import { useUpdateCrucial } from '../../../backend/use-update-crucial';
@ -60,9 +60,9 @@ export function FormConstituenta({ disabled, id, toggleReset, schema, activeCst,
const { updateConstituenta } = useUpdateConstituenta(); const { updateConstituenta } = useUpdateConstituenta();
const { updateCrucial } = useUpdateCrucial(); const { updateCrucial } = useUpdateCrucial();
const { createAssociation } = useCreateAssociation(); const { createAttribution } = useCreateAttribution();
const { deleteAssociation } = useDeleteAssociation(); const { deleteAttribution } = useDeleteAttribution();
const { clearAssociations } = useClearAssociations(); const { clearAttributions } = useClearAttributions();
const showTypification = useDialogsStore(state => state.showShowTypeGraph); const showTypification = useDialogsStore(state => state.showShowTypeGraph);
const showEditTerm = useDialogsStore(state => state.showEditWordForms); const showEditTerm = useDialogsStore(state => state.showEditWordForms);
const showRenameCst = useDialogsStore(state => state.showRenameCst); const showRenameCst = useDialogsStore(state => state.showRenameCst);
@ -113,8 +113,8 @@ export function FormConstituenta({ disabled, id, toggleReset, schema, activeCst,
); );
const associations = useMemo( const associations = useMemo(
() => activeCst.associations.map(id => schema.cstByID.get(id)!), () => activeCst.attributes.map(id => schema.cstByID.get(id)!),
[activeCst.associations, schema.cstByID] [activeCst.attributes, schema.cstByID]
); );
const isBasic = isBasicConcept(activeCst.cst_type) || activeCst.cst_type === CstType.NOMINAL; const isBasic = isBasicConcept(activeCst.cst_type) || activeCst.cst_type === CstType.NOMINAL;
@ -196,28 +196,28 @@ export function FormConstituenta({ disabled, id, toggleReset, schema, activeCst,
}); });
} }
function handleAddAssociation(item: IConstituenta) { function handleAddAttribution(item: IConstituenta) {
void createAssociation({ void createAttribution({
itemID: schema.id, itemID: schema.id,
data: { data: {
container: activeCst.id, container: activeCst.id,
associate: item.id attribute: item.id
} }
}); });
} }
function handleRemoveAssociation(item: IConstituenta) { function handleRemoveAttribution(item: IConstituenta) {
void deleteAssociation({ void deleteAttribution({
itemID: schema.id, itemID: schema.id,
data: { data: {
container: activeCst.id, container: activeCst.id,
associate: item.id attribute: item.id
} }
}); });
} }
function handleClearAssociations() { function handleClearAttributions() {
void clearAssociations({ void clearAttributions({
itemID: schema.id, itemID: schema.id,
data: { data: {
target: activeCst.id target: activeCst.id
@ -279,15 +279,15 @@ export function FormConstituenta({ disabled, id, toggleReset, schema, activeCst,
)} )}
/> />
{activeCst.cst_type === CstType.NOMINAL || activeCst.associations.length > 0 ? ( {activeCst.cst_type === CstType.NOMINAL || activeCst.attributes.length > 0 ? (
<div className='flex flex-col gap-1'> <div className='flex flex-col gap-1'>
<Label text='Ассоциируемые конституенты' /> <Label text='Атрибутирующие конституенты' />
<SelectMultiConstituenta <SelectMultiConstituenta
items={schema.items.filter(item => item.id !== activeCst.id)} items={schema.items.filter(item => item.id !== activeCst.id)}
value={associations} value={associations}
onAdd={handleAddAssociation} onAdd={handleAddAttribution}
onClear={handleClearAssociations} onClear={handleClearAttributions}
onRemove={handleRemoveAssociation} onRemove={handleRemoveAttribution}
disabled={disabled || isModified} disabled={disabled || isModified}
placeholder={disabled ? '' : 'Выберите конституенты'} placeholder={disabled ? '' : 'Выберите конституенты'}
/> />

View File

@ -4,7 +4,7 @@ import { persist } from 'zustand/middleware';
import { CstType } from '../backend/types'; import { CstType } from '../backend/types';
export const graphColorings = ['none', 'status', 'type', 'schemas'] as const; export const graphColorings = ['none', 'status', 'type', 'schemas'] as const;
export const graphTypes = ['full', 'association', 'definition'] as const; export const graphTypes = ['full', 'attribution', 'definition'] as const;
/** Represents graph node coloring scheme. */ /** Represents graph node coloring scheme. */
export type GraphColoring = (typeof graphColorings)[number]; export type GraphColoring = (typeof graphColorings)[number];
@ -104,8 +104,8 @@ export const useTermGraphStore = create<TermGraphStore>()(
...state.filter, ...state.filter,
graphType: graphType:
state.filter.graphType === 'full' state.filter.graphType === 'full'
? 'association' ? 'attribution'
: state.filter.graphType === 'association' : state.filter.graphType === 'attribution'
? 'definition' ? 'definition'
: 'full' : 'full'
} }

View File

@ -28,6 +28,7 @@
@utility cc-hover-pulse { @utility cc-hover-pulse {
&:hover:not(:disabled) { &:hover:not(:disabled) {
transform-origin: center;
animation: pulse-scale var(--duration-cycle) infinite; animation: pulse-scale var(--duration-cycle) infinite;
animation-delay: var(--duration-select); animation-delay: var(--duration-select);
} }