Compare commits

..

46 Commits

Author SHA1 Message Date
Ivan
f3d361f8b4 Merge branch 'main' of http://dev.concept.ru:3000/ConceptProd/Portal
Some checks failed
Backend CI / build (3.12) (push) Has been cancelled
Frontend CI / build (22.x) (push) Has been cancelled
Backend CI / notify-failure (push) Has been cancelled
Frontend CI / notify-failure (push) Has been cancelled
2025-11-13 16:54:25 +03:00
Ivan
ec12e8debd M: Update tailwind spelling 2025-11-12 20:23:04 +03:00
Ivan
8366c5c66b F: Improve attribution propagation on sub change 2025-11-12 13:08:57 +03:00
Ivan
6a2712c499 B: Fix form messages priority 2025-11-11 00:06:50 +03:00
Ivan
974d63550b B: Fix file upload api 2025-11-10 21:43:47 +03:00
Ivan
11a11bb558 F: Improve attribution change propagation 2025-11-10 21:01:59 +03:00
Ivan
6489af44d5 F: Improve dynamic loading error handling 2025-11-09 16:04:04 +03:00
Ivan
d3d4622b1f M: Improve focus cst layout 2025-11-09 15:53:53 +03:00
Ivan
7cda2c5039 M: Improve search prompt for constituents 2025-11-09 15:06:59 +03:00
Ivan
9e409c14f0 R: Refactoring caches pt3 2025-11-09 13:47:16 +03:00
Ivan
0242cd5c20 R: Refactoring caches pt2 2025-11-08 21:44:25 +03:00
Ivan
41e2df21ef R: Refactoring caches pt1 2025-11-08 19:32:42 +03:00
Ivan
b94e643043 B: Fix propagate attribution creation 2025-11-07 23:36:52 +03:00
Ivan
d2782d70fc npm update 2025-11-07 14:04:07 +03:00
Ivan
b30a72e997 M: Improve restore ordering 2025-11-07 13:54:29 +03:00
Ivan
fca8eb71e1 B: Fix self-attribution after substitution 2025-11-07 12:53:32 +03:00
Ivan
b6b9c62b43 R: Rename association -> attributions 2025-11-07 00:07:15 +03:00
Ivan
0c0c58a4d0 R: Refactor constituenta insertion 2025-11-06 23:59:27 +03:00
Ivan
c15edd2d84 M: Minor dev fixes 2025-11-06 16:19:48 +03:00
Ivan
6cfc695bbe R: Prevent potential infinite loops 2025-11-06 15:38:38 +03:00
Ivan
fd6f88cfdf M: Remove unnecessary rerender 2025-11-06 15:26:39 +03:00
Ivan
6b01d9afb2 B: Fix potential memory leaks 2025-11-06 15:22:25 +03:00
Ivan
0dd8f850ba M: Improve token handling messages 2025-11-06 15:06:41 +03:00
Ivan
17f30f0b20 M: Improve error handling 2025-11-06 15:01:51 +03:00
Ivan
921310a349 F: Improve error handling security 2025-11-06 14:52:22 +03:00
Ivan
9f03b00818 F: Reload when failed to load stale module 2025-11-06 14:29:07 +03:00
Ivan
5928d79acb B: Fix tooltips not working for first load in Constituenta Editor 2025-11-06 01:56:11 +03:00
Ivan
61afb36d68 F: Disable ligatures for math 2025-11-06 01:10:26 +03:00
Ivan
30d24f4fb6 F: Copy attributions when cloning schema for import 2025-11-05 19:18:01 +03:00
Ivan
dbe627a394 T: Improve clone tests 2025-11-05 17:18:22 +03:00
Ivan
6563afe45b B: Prevent self substitutitons in the UI 2025-11-05 16:43:43 +03:00
Ivan
d5ccf1b9a9 F: Improve template dialog 2025-11-04 20:12:54 +03:00
Ivan
820e567557 B: Fix alert symbol positioning 2025-11-04 14:08:31 +03:00
Ivan
eedc470f9a M: Improve focus cst label positioning 2025-11-04 13:42:57 +03:00
Ivan
924a169224 F: Improve dialogs validation info 2025-11-04 01:49:49 +03:00
Ivan
e010513a8a f 2025-11-04 01:45:36 +03:00
Ivan
cd2c4fe0e1 R: Use canonical classnames 2025-11-03 19:58:03 +03:00
Ivan
e616dd12be F: Add schema-title for visibility 2025-11-03 19:52:34 +03:00
Ivan
6b7f56ec39 F: Replace ligature combinations with unicode symbols 2025-10-31 17:55:42 +03:00
Ivan
6ba05b79bf B: Fix reset handler 2025-10-31 12:08:54 +03:00
Ivan
2bed8b354c M: Fix scroll overflow 2025-10-30 19:23:18 +03:00
Ivan
a7f4637b46 npm update and linter fix 2025-10-27 16:13:47 +03:00
Ivan
bfcc43457c F: Implement attribution merge on substitution 2025-10-27 13:02:12 +03:00
Ivan
531bd0e3d2 update python dependencies 2025-10-23 17:26:39 +03:00
Ivan
ae9adf4eee M: Fix tooltip size for small screens 2025-10-20 13:38:03 +03:00
Ivan
ffc5b140ca B: Fix cst graph visualization 2025-10-15 12:33:48 +03:00
132 changed files with 2394 additions and 1739 deletions

View File

@ -185,6 +185,7 @@
"Акименков",
"Астрина",
"Атрибутирование",
"Атрибутирования",
"Атрибутирующая",
"Атрибутирующие",
"Ашихмин",

View File

@ -30,6 +30,7 @@ This readme file is used mostly to document project dependencies and conventions
<pre>
- axios
- clsx
- dompurify
- react-icons
- react-router
- react-toastify

View File

@ -72,7 +72,7 @@ ignored-modules=
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
# number of processors available to use, and will cap the count on Windows to
# avoid hangs.
jobs=1
jobs=0
# Control the amount of potential inferred values when inferring a single
# object. This can help the performance when dealing with large functions or
@ -88,7 +88,7 @@ persistent=yes
# Minimum Python version to use for version dependent checks. Will default to
# the version used to run pylint.
py-version=3.9
py-version=3.10
# Discover python modules and packages in the file system subtree.
recursive=no
@ -99,10 +99,6 @@ recursive=no
# source root.
source-roots=
# When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages.
suggestion-mode=yes
# Allow loading of arbitrary C extensions. Extensions are imported into the
# active Python interpreter and may run arbitrary code.
unsafe-load-any-extension=no

View File

@ -54,7 +54,7 @@ class LibraryItemCloneSerializer(StrictSerializer):
model = LibraryItem
exclude = ['id', 'item_type', 'owner', 'read_only']
items = PKField(many=True, queryset=Constituenta.objects.all().only('pk', 'schema_id'))
items = PKField(many=True, queryset=Constituenta.objects.all().only('schema_id'))
item_data = ItemCloneData()
def validate_items(self, value):

View File

@ -1,4 +1,6 @@
''' Testing API: Library. '''
from typing import Any
from rest_framework import status
from apps.library.models import (
@ -358,6 +360,8 @@ class TestLibraryViewset(EndpointTester):
self.assertEqual(response.data['items'][0]['term_resolved'], x12.term_resolved)
self.assertEqual(response.data['items'][1]['term_raw'], d2.term_raw)
self.assertEqual(response.data['items'][1]['term_resolved'], d2.term_resolved)
self.assertEqual(response.data['attribution'][0]['attribute'], response.data['items'][0]['id'])
self.assertEqual(response.data['attribution'][0]['container'], response.data['items'][1]['id'])
data = {'item_data': {'title': 'Title1340'}, 'items': []}
response = self.executeCreated(data, item=self.owned.pk)
@ -371,3 +375,27 @@ class TestLibraryViewset(EndpointTester):
self.assertEqual(response.data['items'][0]['alias'], x12.alias)
self.assertEqual(response.data['items'][0]['term_raw'], x12.term_raw)
self.assertEqual(response.data['items'][0]['term_resolved'], x12.term_resolved)
@decl_endpoint('/api/library/{item}/clone', method='post')
def test_clone_rsform_partial(self):
schema = RSForm(self.owned)
x1 = schema.insert_last(alias='X1')
x2 = schema.insert_last(alias='X2')
d1 = schema.insert_last(alias='D1')
Attribution.objects.create(container=x2, attribute=x1)
Attribution.objects.create(container=d1, attribute=x2)
# Only clone x2 and d1
data = {'item_data': {'title': 'Cloned'}, 'items': [x2.pk, d1.pk]}
response = self.executeCreated(data, item=self.owned.pk)
self.assertEqual(response.data['title'], data['item_data']['title'])
self.assertEqual(len(response.data['items']), 2)
aliases = set[Any](item['alias'] for item in response.data['items'])
self.assertIn(x2.alias, aliases)
self.assertIn(d1.alias, aliases)
self.assertEqual(len(response.data['attribution']), 1)
self.assertEqual(response.data['attribution'][0]['container'], response.data['items'][1]['id'])
self.assertEqual(response.data['attribution'][0]['attribute'], response.data['items'][0]['id'])

View File

@ -14,7 +14,7 @@ from rest_framework.request import Request
from rest_framework.response import Response
from apps.oss.models import Layout, Operation, OperationSchema, PropagationFacade
from apps.rsform.models import Attribution, RSFormCached
from apps.rsform.models import RSFormCached
from apps.rsform.serializers import RSFormParseSerializer
from apps.users.models import User
from shared import permissions
@ -67,7 +67,7 @@ class LibraryViewSet(viewsets.ModelViewSet):
def perform_destroy(self, instance: m.LibraryItem) -> None:
if instance.item_type == m.LibraryItemType.RSFORM:
PropagationFacade.before_delete_schema(instance)
PropagationFacade().before_delete_schema(instance.pk)
super().perform_destroy(instance)
if instance.item_type == m.LibraryItemType.OPERATION_SCHEMA:
schemas = list(OperationSchema.owned_schemasQ(instance))
@ -172,22 +172,7 @@ class LibraryViewSet(viewsets.ModelViewSet):
clone.location = data.get('location', m.LocationHead.USER)
clone.save()
cst_map: dict[int, int] = {}
cst_list: list[int] = []
need_filter = 'items' in request.data and request.data['items']
for cst in RSFormCached(item).constituentsQ():
if not need_filter or cst.pk in request.data['items']:
old_pk = cst.pk
cst.pk = None
cst.schema = clone
cst.save()
cst_map[old_pk] = cst.pk
cst_list.append(old_pk)
for attr in Attribution.objects.filter(container__in=cst_list, attribute__in=cst_list):
attr.pk = None
attr.container_id = cst_map[attr.container_id]
attr.attribute_id = cst_map[attr.attribute_id]
attr.save()
RSFormCached(clone.pk).insert_from(item.pk, request.data['items'] if 'items' in request.data else None)
return Response(
status=c.HTTP_201_CREATED,

View File

@ -1,6 +1,8 @@
''' Models: Synthesis Inheritance. '''
from django.db.models import CASCADE, ForeignKey, Model
from .Substitution import Substitution
class Inheritance(Model):
''' Inheritance links parent and child constituents in synthesis operation.'''
@ -32,3 +34,32 @@ class Inheritance(Model):
def __str__(self) -> str:
return f'{self.parent} -> {self.child}'
@staticmethod
def check_share_origin(cst1: int, cst2: int) -> bool:
''' Check if two constituents share origin. '''
inheritance1 = Inheritance.objects.filter(child_id=cst1).first()
if not inheritance1:
return False
inheritance2 = Inheritance.objects.filter(child_id=cst2).first()
if not inheritance2:
return False
parent1 = inheritance1.parent
parent2 = inheritance2.parent
origins1 = list(
Substitution.objects.filter(
substitution=parent1).values_list(
'original__schema_id',
flat=True))
origins1.append(parent1.schema_id)
origins2 = list(
Substitution.objects.filter(
substitution=parent2).values_list(
'original__schema_id',
flat=True))
origins2.append(parent2.schema_id)
return any(x in origins1 for x in origins2)

View File

@ -25,7 +25,7 @@ class Layout(Model):
return f'Схема расположения {self.oss.alias}'
@staticmethod
def update_data(itemID: int, data: dict) -> None:
def update_data(itemID: int, data: list) -> None:
''' Update layout data. '''
layout = Layout.objects.get(oss_id=itemID)
layout.data = data

View File

@ -18,7 +18,7 @@ from .Substitution import Substitution
class OperationSchema:
''' Operations schema API wrapper. No caching, propagation and minimal side effects. '''
def __init__(self, model: LibraryItem):
def __init__(self, model: LibraryItem) -> None:
self.model = model
@staticmethod
@ -43,19 +43,18 @@ class OperationSchema:
return Layout.objects.get(oss_id=itemID)
@staticmethod
def create_input(oss: LibraryItem, operation: Operation) -> RSFormCached:
def create_input(oss_id: int, operation: Operation) -> LibraryItem:
''' Create input RSForm for given Operation. '''
schema = RSFormCached.create(
owner=oss.owner,
alias=operation.alias,
title=operation.title,
description=operation.description,
visible=False,
access_policy=oss.access_policy,
location=oss.location
)
Editor.set(schema.model.pk, oss.getQ_editors().values_list('pk', flat=True))
operation.setQ_result(schema.model)
oss = LibraryItem.objects.get(pk=oss_id)
schema = LibraryItem.objects.create(item_type=LibraryItemType.RSFORM, owner=oss.owner,
alias=operation.alias,
title=operation.title,
description=operation.description,
visible=False,
access_policy=oss.access_policy,
location=oss.location)
Editor.set(schema.pk, oss.getQ_editors().values_list('pk', flat=True))
operation.setQ_result(schema)
return schema
def refresh_from_db(self) -> None:
@ -132,16 +131,15 @@ class OperationSchema:
if not schemas:
return
substitutions = operation.getQ_substitutions()
receiver = OperationSchema.create_input(self.model, operation)
receiver = RSFormCached(OperationSchema.create_input(self.model.pk, operation).pk)
parents: dict = {}
children: dict = {}
for operand in schemas:
items = list(Constituenta.objects.filter(schema_id=operand).order_by('order'))
new_items = receiver.insert_copy(items)
for (i, cst) in enumerate(new_items):
parents[cst.pk] = items[i]
children[items[i].pk] = cst
new_items = receiver.insert_from(operand)
for (old_cst, new_cst) in new_items:
parents[new_cst.pk] = old_cst
children[old_cst.pk] = new_cst
translated_substitutions: list[tuple[Constituenta, Constituenta]] = []
for sub in substitutions:
@ -150,7 +148,7 @@ class OperationSchema:
translated_substitutions.append((original, replacement))
receiver.substitute(translated_substitutions)
for cst in Constituenta.objects.filter(schema=receiver.model).order_by('order'):
for cst in Constituenta.objects.filter(schema_id=receiver.pk).order_by('order'):
parent = parents.get(cst.pk)
assert parent is not None
Inheritance.objects.create(

View File

@ -11,6 +11,7 @@ from .Inheritance import Inheritance
from .Operation import Operation
from .OperationSchema import OperationSchema
from .OssCache import OssCache
from .PropagationContext import PropagationContext
from .PropagationEngine import PropagationEngine
from .Substitution import Substitution
from .utils import CstMapping, CstSubstitution, create_dependant_mapping, extract_data_references
@ -19,10 +20,11 @@ from .utils import CstMapping, CstSubstitution, create_dependant_mapping, extrac
class OperationSchemaCached:
''' Operations schema API with caching. '''
def __init__(self, model: LibraryItem):
self.model = model
self.cache = OssCache(model.pk)
self.engine = PropagationEngine(self.cache)
def __init__(self, item_id: int, context: PropagationContext) -> None:
self.pk = item_id
self.context = context
self.cache = OssCache(item_id, context)
self.engine = PropagationEngine(self.cache, context)
def delete_replica(self, target: int, keep_connections: bool = False, keep_constituents: bool = False):
''' Delete Replica Operation. '''
@ -53,7 +55,7 @@ class OperationSchemaCached:
inheritance_to_delete: list[Inheritance] = []
for child_id in children:
child_operation = self.cache.operation_by_id[child_id]
child_schema = self.cache.get_schema(child_operation)
child_schema = self.cache.get_result(child_operation)
if child_schema is None:
continue
self.engine.undo_substitutions_cst(ids, child_operation, child_schema)
@ -70,15 +72,15 @@ class OperationSchemaCached:
''' Set input schema for operation. '''
operation = self.cache.operation_by_id[target]
has_children = bool(self.cache.extend_graph.outputs[target])
old_schema = self.cache.get_schema(operation)
old_schema = self.cache.get_result(operation)
if schema is None and old_schema is None or \
(schema is not None and old_schema is not None and schema.pk == old_schema.model.pk):
(schema is not None and old_schema is not None and schema.pk == old_schema.pk):
return
if old_schema is not None:
if has_children:
self.before_delete_cst(old_schema.model.pk, [cst.pk for cst in old_schema.cache.constituents])
self.cache.remove_schema(old_schema)
self.before_delete_cst(old_schema.pk, [cst.pk for cst in old_schema.cache.constituents])
self.context.invalidate(old_schema.pk)
operation.setQ_result(schema)
if schema is not None:
@ -88,8 +90,8 @@ class OperationSchemaCached:
operation.save(update_fields=['alias', 'title', 'description'])
if schema is not None and has_children:
rsform = RSFormCached(schema)
self.after_create_cst(rsform, list(rsform.constituentsQ().order_by('order')))
cst_list = list(Constituenta.objects.filter(schema_id=schema.pk).order_by('order'))
self.after_create_cst(schema.pk, cst_list)
def set_arguments(self, target: int, arguments: list[Operation]) -> None:
''' Set arguments of target Operation. '''
@ -126,7 +128,7 @@ class OperationSchemaCached:
''' Clear all arguments for target Operation. '''
self.cache.ensure_loaded_subs()
operation = self.cache.operation_by_id[target]
schema = self.cache.get_schema(operation)
schema = self.cache.get_result(operation)
processed: list[dict] = []
deleted: list[Substitution] = []
for current in operation.getQ_substitutions():
@ -172,17 +174,16 @@ class OperationSchemaCached:
if not schemas:
return False
substitutions = operation.getQ_substitutions()
receiver = OperationSchema.create_input(self.model, self.cache.operation_by_id[operation.pk])
self.cache.insert_schema(receiver)
new_schema = OperationSchema.create_input(self.pk, self.cache.operation_by_id[operation.pk])
receiver = self.context.get_schema(new_schema.pk)
parents: dict = {}
children: dict = {}
for operand in schemas:
items = list(Constituenta.objects.filter(schema_id=operand).order_by('order'))
new_items = receiver.insert_copy(items)
for (i, cst) in enumerate(new_items):
parents[cst.pk] = items[i]
children[items[i].pk] = cst
new_items = receiver.insert_from(operand)
for (old_cst, new_cst) in new_items:
parents[new_cst.pk] = old_cst
children[old_cst.pk] = new_cst
translated_substitutions: list[tuple[Constituenta, Constituenta]] = []
for sub in substitutions:
@ -191,7 +192,7 @@ class OperationSchemaCached:
translated_substitutions.append((original, replacement))
receiver.substitute(translated_substitutions)
for cst in Constituenta.objects.filter(schema=receiver.model).order_by('order'):
for cst in Constituenta.objects.filter(schema_id=receiver.pk).order_by('order'):
parent = parents.get(cst.pk)
assert parent is not None
Inheritance.objects.create(
@ -205,31 +206,31 @@ class OperationSchemaCached:
receiver.resolve_all_text()
if self.cache.extend_graph.outputs[operation.pk]:
receiver_items = list(Constituenta.objects.filter(schema=receiver.model).order_by('order'))
self.after_create_cst(receiver, receiver_items)
receiver.model.save(update_fields=['time_update'])
receiver_items = list(Constituenta.objects.filter(schema_id=receiver.pk).order_by('order'))
self.after_create_cst(receiver.pk, receiver_items)
return True
def relocate_down(self, source: RSFormCached, destination: RSFormCached, items: list[int]):
def relocate_down(self, destinationID: int, items: list[int]):
''' Move list of Constituents to destination Schema inheritor. '''
self.cache.ensure_loaded_subs()
self.cache.insert_schema(source)
self.cache.insert_schema(destination)
operation = self.cache.get_operation(destination.model.pk)
operation = self.cache.get_operation(destinationID)
destination = self.context.get_schema(destinationID)
self.engine.undo_substitutions_cst(items, operation, destination)
inheritance_to_delete = [item for item in self.cache.inheritance[operation.pk] if item.parent_id in items]
for item in inheritance_to_delete:
self.cache.remove_inheritance(item)
Inheritance.objects.filter(operation_id=operation.pk, parent_id__in=items).delete()
def relocate_up(self, source: RSFormCached, destination: RSFormCached,
items: list[Constituenta]) -> list[Constituenta]:
def relocate_up(self, sourceID: int, destinationID: int,
item_ids: list[int]) -> list[Constituenta]:
''' Move list of Constituents upstream to destination Schema. '''
self.cache.ensure_loaded_subs()
self.cache.insert_schema(source)
self.cache.insert_schema(destination)
operation = self.cache.get_operation(source.model.pk)
source = self.context.get_schema(sourceID)
destination = self.context.get_schema(destinationID)
operation = self.cache.get_operation(sourceID)
alias_mapping: dict[str, str] = {}
for item in self.cache.inheritance[operation.pk]:
if item.parent_id in destination.cache.by_id:
@ -237,27 +238,27 @@ class OperationSchemaCached:
destination_cst = destination.cache.by_id[item.parent_id]
alias_mapping[source_cst.alias] = destination_cst.alias
new_items = destination.insert_copy(items, initial_mapping=alias_mapping)
for index, cst in enumerate(new_items):
new_items = destination.insert_from(sourceID, item_ids, alias_mapping)
for (cst, new_cst) in new_items:
new_inheritance = Inheritance.objects.create(
operation=operation,
child=items[index],
parent=cst
child=cst,
parent=new_cst
)
self.cache.insert_inheritance(new_inheritance)
self.after_create_cst(destination, new_items, exclude=[operation.pk])
destination.model.save(update_fields=['time_update'])
return new_items
new_constituents = [item[1] for item in new_items]
self.after_create_cst(destinationID, new_constituents, exclude=[operation.pk])
return new_constituents
def after_create_cst(
self, source: RSFormCached,
self, sourceID: int,
cst_list: list[Constituenta],
exclude: Optional[list[int]] = None
) -> None:
''' Trigger cascade resolutions when new Constituenta is created. '''
self.cache.insert_schema(source)
source = self.context.get_schema(sourceID)
alias_mapping = create_dependant_mapping(source, cst_list)
operation = self.cache.get_operation(source.model.pk)
operation = self.cache.get_operation(source.pk)
self.engine.on_inherit_cst(operation.pk, source, cst_list, alias_mapping, exclude)
def after_change_cst_type(self, schemaID: int, target: int, new_type: CstType) -> None:
@ -265,10 +266,10 @@ class OperationSchemaCached:
operation = self.cache.get_operation(schemaID)
self.engine.on_change_cst_type(operation.pk, target, new_type)
def after_update_cst(self, source: RSFormCached, target: int, data: dict, old_data: dict) -> None:
def after_update_cst(self, sourceID: int, target: int, data: dict, old_data: dict) -> None:
''' Trigger cascade resolutions when Constituenta data is changed. '''
self.cache.insert_schema(source)
operation = self.cache.get_operation(source.model.pk)
operation = self.cache.get_operation(sourceID)
source = self.context.get_schema(sourceID)
depend_aliases = extract_data_references(data, old_data)
alias_mapping: CstMapping = {}
for alias in depend_aliases:
@ -298,18 +299,18 @@ class OperationSchemaCached:
if target.result_id is None:
return
for argument in arguments:
parent_schema = self.cache.get_schema(argument)
if parent_schema is not None:
parent_schema = self.cache.get_result(argument)
if parent_schema:
self.engine.delete_inherited(target.pk, [cst.pk for cst in parent_schema.cache.constituents])
def after_create_arguments(self, target: Operation, arguments: list[Operation]) -> None:
''' Trigger cascade resolutions after arguments are created. '''
schema = self.cache.get_schema(target)
if schema is None:
schema = self.cache.get_result(target)
if not schema:
return
for argument in arguments:
parent_schema = self.cache.get_schema(argument)
if parent_schema is None:
parent_schema = self.cache.get_result(argument)
if not parent_schema:
continue
self.engine.inherit_cst(
target_operation=target.pk,
@ -318,16 +319,16 @@ class OperationSchemaCached:
mapping={}
)
def after_create_attribution(self, schemaID: int, associations: list[Attribution],
def after_create_attribution(self, schemaID: int, attributions: list[Attribution],
exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions when Attribution is created. '''
operation = self.cache.get_operation(schemaID)
self.engine.on_inherit_attribution(operation.pk, associations, exclude)
self.engine.on_inherit_attribution(operation.pk, attributions, exclude)
def before_delete_attribution(self, schemaID: int, associations: list[Attribution]) -> None:
def before_delete_attribution(self, schemaID: int, attributions: list[Attribution]) -> None:
''' Trigger cascade resolutions when Attribution is deleted. '''
operation = self.cache.get_operation(schemaID)
self.engine.on_delete_attribution(operation.pk, associations)
self.engine.on_delete_attribution(operation.pk, attributions)
def _on_add_substitutions(self, schema: Optional[RSFormCached], added: list[Substitution]) -> None:
''' Trigger cascade resolutions when Constituenta substitution is added. '''
@ -347,7 +348,7 @@ class OperationSchemaCached:
original_cst = schema.cache.by_id[original_id]
substitution_cst = schema.cache.by_id[substitution_id]
cst_mapping.append((original_cst, substitution_cst))
self.before_substitute(schema.model.pk, cst_mapping)
self.before_substitute(schema.pk, cst_mapping)
schema.substitute(cst_mapping)
for sub in added:
self.cache.insert_substitution(sub)

View File

@ -8,6 +8,7 @@ from apps.rsform.models import RSFormCached
from .Argument import Argument
from .Inheritance import Inheritance
from .Operation import Operation, OperationType
from .PropagationContext import PropagationContext
from .Replica import Replica
from .Substitution import Substitution
@ -15,10 +16,9 @@ from .Substitution import Substitution
class OssCache:
''' Cache for OSS data. '''
def __init__(self, item_id: int):
def __init__(self, item_id: int, context: PropagationContext) -> None:
self._item_id = item_id
self._schemas: list[RSFormCached] = []
self._schema_by_id: dict[int, RSFormCached] = {}
self._context = context
self.operations = list(Operation.objects.filter(oss_id=item_id).only('result_id', 'operation_type'))
self.operation_by_id = {operation.pk: operation for operation in self.operations}
@ -60,27 +60,11 @@ class OssCache:
'operation_id', 'parent_id', 'child_id'):
self.inheritance[item.operation_id].append(item)
def get_schema(self, operation: Operation) -> Optional[RSFormCached]:
def get_result(self, operation: Operation) -> Optional[RSFormCached]:
''' Get schema by Operation. '''
if operation.result_id is None:
return None
if operation.result_id in self._schema_by_id:
return self._schema_by_id[operation.result_id]
else:
schema = RSFormCached.from_id(operation.result_id)
schema.cache.ensure_loaded()
self._insert_new(schema)
return schema
def get_schema_by_id(self, target: int) -> RSFormCached:
''' Get schema by Operation. '''
if target in self._schema_by_id:
return self._schema_by_id[target]
else:
schema = RSFormCached.from_id(target)
schema.cache.ensure_loaded()
self._insert_new(schema)
return schema
return self._context.get_schema(operation.result_id)
def get_operation(self, schemaID: int) -> Operation:
''' Get operation by schema. '''
@ -111,11 +95,15 @@ class OssCache:
return self.get_inheritor(sub.substitution_id, operation)
return self.get_inheritor(parent_cst, operation)
def insert_schema(self, schema: RSFormCached) -> None:
''' Insert new schema. '''
if not self._schema_by_id.get(schema.model.pk):
schema.cache.ensure_loaded()
self._insert_new(schema)
def get_substitution_partners(self, cst: int, operation: int) -> list[int]:
''' Get originals or substitutes for target constituent in target operation. '''
result = []
for sub in self.substitutions[operation]:
if sub.original_id == cst:
result.append(sub.substitution_id)
elif sub.substitution_id == cst:
result.append(sub.original_id)
return result
def insert_argument(self, argument: Argument) -> None:
''' Insert new argument. '''
@ -145,19 +133,12 @@ class OssCache:
for item in inherit_to_delete:
self.inheritance[operation].remove(item)
def remove_schema(self, schema: RSFormCached) -> None:
''' Remove schema from cache. '''
self._schemas.remove(schema)
del self._schema_by_id[schema.model.pk]
def remove_operation(self, operation: int) -> None:
''' Remove operation from cache. '''
target = self.operation_by_id[operation]
self.graph.remove_node(operation)
self.extend_graph.remove_node(operation)
if target.result_id in self._schema_by_id:
self._schemas.remove(self._schema_by_id[target.result_id])
del self._schema_by_id[target.result_id]
self._context.invalidate(target.result_id)
self.operations.remove(self.operation_by_id[operation])
del self.operation_by_id[operation]
if operation in self.replica_original:
@ -182,7 +163,3 @@ class OssCache:
def remove_inheritance(self, target: Inheritance) -> None:
''' Remove inheritance from cache. '''
self.inheritance[target.operation_id].remove(target)
def _insert_new(self, schema: RSFormCached) -> None:
self._schemas.append(schema)
self._schema_by_id[schema.model.pk] = schema

View File

@ -0,0 +1,27 @@
''' Models: Propagation context. '''
from apps.rsform.models import RSFormCached
class PropagationContext:
''' Propagation context. '''
def __init__(self) -> None:
self._cache: dict[int, RSFormCached] = {}
def get_schema(self, item_id: int) -> RSFormCached:
''' Get schema by ID. '''
if item_id not in self._cache:
schema = RSFormCached(item_id)
schema.cache.ensure_loaded()
self._cache[item_id] = schema
return self._cache[item_id]
def clear(self) -> None:
''' Clear cache. '''
self._cache = {}
def invalidate(self, item_id: int | None) -> None:
''' Invalidate schema by ID. '''
if item_id in self._cache:
del self._cache[item_id]

View File

@ -1,13 +1,15 @@
''' Models: Change propagation engine. '''
from typing import Optional
from django.db.models import Q
from rest_framework.serializers import ValidationError
from apps.rsform.models import INSERT_LAST, Attribution, Constituenta, CstType, RSFormCached
from apps.rsform.models import Attribution, Constituenta, CstType, RSFormCached
from .Inheritance import Inheritance
from .Operation import Operation
from .OssCache import OssCache
from .PropagationContext import PropagationContext
from .Substitution import Substitution
from .utils import (
CstMapping,
@ -21,8 +23,9 @@ from .utils import (
class PropagationEngine:
''' OSS changes propagation engine. '''
def __init__(self, cache: OssCache):
def __init__(self, cache: OssCache, context: PropagationContext) -> None:
self.cache = cache
self.context = context
def on_change_cst_type(self, operation_id: int, cst_id: int, ctype: CstType) -> None:
''' Trigger cascade resolutions when Constituenta type is changed. '''
@ -35,7 +38,7 @@ class PropagationEngine:
successor_id = self.cache.get_inheritor(cst_id, child_id)
if successor_id is None:
continue
child_schema = self.cache.get_schema(child_operation)
child_schema = self.cache.get_result(child_operation)
if child_schema is None:
continue
if child_schema.change_cst_type(successor_id, ctype):
@ -67,7 +70,7 @@ class PropagationEngine:
) -> None:
''' Execute inheritance of Constituenta. '''
operation = self.cache.operation_by_id[target_operation]
destination = self.cache.get_schema(operation)
destination = self.cache.get_result(operation)
if destination is None:
return
@ -76,11 +79,11 @@ class PropagationEngine:
alias_mapping = cst_mapping_to_alias(new_mapping)
insert_where = self._determine_insert_position(items[0].pk, operation, source, destination)
new_cst_list = destination.insert_copy(items, insert_where, alias_mapping)
for index, cst in enumerate(new_cst_list):
for (cst, new_cst) in zip(items, new_cst_list):
new_inheritance = Inheritance.objects.create(
operation=operation,
child=cst,
parent=items[index]
child=new_cst,
parent=cst
)
self.cache.insert_inheritance(new_inheritance)
new_mapping = {alias_mapping[alias]: cst for alias, cst in new_mapping.items()}
@ -88,9 +91,7 @@ class PropagationEngine:
# pylint: disable=too-many-arguments, too-many-positional-arguments
def on_update_cst(
self,
operation: int,
cst_id: int,
self, operation: int, cst_id: int,
data: dict, old_data: dict,
mapping: CstMapping
) -> None:
@ -104,7 +105,7 @@ class PropagationEngine:
successor_id = self.cache.get_inheritor(cst_id, child_id)
if successor_id is None:
continue
child_schema = self.cache.get_schema(child_operation)
child_schema = self.cache.get_result(child_operation)
assert child_schema is not None
new_mapping = self._transform_mapping(mapping, child_operation, child_schema)
alias_mapping = cst_mapping_to_alias(new_mapping)
@ -126,47 +127,49 @@ class PropagationEngine:
mapping=new_mapping
)
def on_inherit_attribution(self, operationID: int,
items: list[Attribution],
exclude: Optional[list[int]] = None) -> None:
def on_inherit_attribution(
self, operationID: int,
items: list[Attribution],
exclude: Optional[list[int]] = None
) -> None:
''' Trigger cascade resolutions when Attribution is inherited. '''
children = self.cache.extend_graph.outputs[operationID]
if not children:
return
for child_id in children:
if not exclude or child_id not in exclude:
self.inherit_association(child_id, items)
self.inherit_attributions(child_id, items)
def inherit_association(self, target: int, items: list[Attribution]) -> None:
''' Execute inheritance of Associations. '''
def inherit_attributions(self, target: int, items: list[Attribution]) -> None:
''' Execute inheritance of Attributions. '''
operation = self.cache.operation_by_id[target]
if operation.result is None or not items:
return
self.cache.ensure_loaded_subs()
existing_associations = set(
existing_attributions = set(
Attribution.objects.filter(
container__schema_id=operation.result_id,
).values_list('container_id', 'attribute_id')
)
new_associations: list[Attribution] = []
for assoc in items:
new_container = self.cache.get_inheritor(assoc.container_id, target)
new_attribute = self.cache.get_inheritor(assoc.attribute_id, target)
new_attributions: list[Attribution] = []
for attrib in items:
new_container = self.cache.get_successor(attrib.container_id, target)
new_attribute = self.cache.get_successor(attrib.attribute_id, target)
if new_container is None or new_attribute is None \
or new_attribute == new_container \
or (new_container, new_attribute) in existing_associations:
or (new_container, new_attribute) in existing_attributions:
continue
new_associations.append(Attribution(
new_attributions.append(Attribution(
container_id=new_container,
attribute_id=new_attribute
))
if new_associations:
new_associations = Attribution.objects.bulk_create(new_associations)
self.on_inherit_attribution(target, new_associations)
if new_attributions:
new_attributions = Attribution.objects.bulk_create(new_attributions)
self.on_inherit_attribution(target, new_attributions)
def on_before_substitute(self, operationID: int, substitutions: CstSubstitution) -> None:
''' Trigger cascade resolutions when Constituenta substitution is executed. '''
@ -176,7 +179,7 @@ class PropagationEngine:
self.cache.ensure_loaded_subs()
for child_id in children:
child_operation = self.cache.operation_by_id[child_id]
child_schema = self.cache.get_schema(child_operation)
child_schema = self.cache.get_result(child_operation)
if child_schema is None:
continue
new_substitutions = self._transform_substitutions(substitutions, child_id, child_schema)
@ -185,57 +188,75 @@ class PropagationEngine:
self.on_before_substitute(child_operation.pk, new_substitutions)
child_schema.substitute(new_substitutions)
def on_delete_attribution(self, operationID: int, associations: list[Attribution]) -> None:
def on_delete_attribution(self, operationID: int, attributions: list[Attribution]) -> None:
''' Trigger cascade resolutions when Attribution is deleted. '''
children = self.cache.extend_graph.outputs[operationID]
if not children:
return
self.cache.ensure_loaded_subs()
for child_id in children:
child_operation = self.cache.operation_by_id[child_id]
child_schema = self.cache.get_schema(child_operation)
if child_schema is None:
self._delete_child_attributions(child_id, attributions)
def _delete_child_attributions(self, operationID: int, attributions: list[Attribution]) -> None:
child_operation = self.cache.operation_by_id[operationID]
child_schema = self.cache.get_result(child_operation)
if child_schema is None:
return
deleted: list[Attribution] = []
for attr in attributions:
new_container = self.cache.get_successor(attr.container_id, operationID)
new_attribute = self.cache.get_successor(attr.attribute_id, operationID)
if new_container is None or new_attribute is None:
continue
deleted_attr = Attribution.objects.filter(
container=new_container,
attribute=new_attribute
).first()
if not deleted_attr:
continue
deleted: list[Attribution] = []
for attr in associations:
new_container = self.cache.get_inheritor(attr.container_id, child_id)
new_attribute = self.cache.get_inheritor(attr.attribute_id, child_id)
if new_container is None or new_attribute is None:
continue
deleted_assoc = Attribution.objects.filter(
container=new_container,
attribute=new_attribute
)
if deleted_assoc.exists():
deleted.append(deleted_assoc[0])
if deleted:
self.on_delete_attribution(child_id, deleted)
Attribution.objects.filter(pk__in=[assoc.pk for assoc in deleted]).delete()
if not self._has_alternative_attribution(operationID, attr.container_id, attr.attribute_id):
deleted.append(deleted_attr)
def on_delete_inherited(self, operation: int, target: list[int]) -> None:
if deleted:
self.on_delete_attribution(operationID, deleted)
Attribution.objects.filter(pk__in=[attrib.pk for attrib in deleted]).delete()
def _has_alternative_attribution(self, operationID: int, container: int, attribute: int) -> bool:
''' Check if there is an alternative attribution among substitutions. '''
container_partners = self.cache.get_substitution_partners(container, operationID)
attribute_partners = self.cache.get_substitution_partners(attribute, operationID)
if not container_partners or not attribute_partners:
return False
return Attribution.objects.filter(container__in=container_partners, attribute__in=attribute_partners).exists()
def on_delete_inherited(self, operationID: int, target: list[int]) -> None:
''' Trigger cascade resolutions when Constituenta inheritance is deleted. '''
children = self.cache.extend_graph.outputs[operation]
children = self.cache.extend_graph.outputs[operationID]
if not children:
return
self.cache.ensure_loaded_subs()
for child_id in children:
self.delete_inherited(child_id, target)
def delete_inherited(self, operation_id: int, parent_ids: list[int]) -> None:
def delete_inherited(self, operationID: int, parents: list[int]) -> None:
''' Execute deletion of Constituenta inheritance. '''
operation = self.cache.operation_by_id[operation_id]
schema = self.cache.get_schema(operation)
operation = self.cache.operation_by_id[operationID]
schema = self.cache.get_result(operation)
if schema is None:
return
self.undo_substitutions_cst(parent_ids, operation, schema)
target_ids = self.cache.get_inheritors_list(parent_ids, operation_id)
self.on_delete_inherited(operation_id, target_ids)
self.undo_substitutions_cst(parents, operation, schema)
target_ids = self.cache.get_inheritors_list(parents, operationID)
self.on_delete_inherited(operationID, target_ids)
if target_ids:
self.cache.remove_cst(operation_id, target_ids)
self.cache.remove_cst(operationID, target_ids)
schema.delete_cst(target_ids)
def undo_substitutions_cst(self, target_ids: list[int], operation: Operation, schema: RSFormCached) -> None:
def undo_substitutions_cst(
self, target_ids: list[int],
operation: Operation, schema: RSFormCached
) -> None:
''' Undo substitutions for Constituents. '''
to_process = []
for sub in self.cache.substitutions[operation.pk]:
@ -245,17 +266,23 @@ class PropagationEngine:
self.undo_substitution(schema, sub, target_ids)
def undo_substitution(
self,
schema: RSFormCached,
target: Substitution,
ignore_parents: Optional[list[int]] = None
self, schema: RSFormCached, target: Substitution,
ignore_parents: Optional[list[int]] = None
) -> None:
''' Undo target substitution. '''
if ignore_parents is None:
ignore_parents = []
operation_id = target.operation_id
original_schema = self.cache.get_schema_by_id(target.original.schema_id)
original_attributions = list(Attribution.objects.filter(
Q(container=target.original_id) |
Q(attribute=target.original_id)
))
if original_attributions:
self._delete_child_attributions(operation_id, original_attributions)
dependant = []
original_schema = self.context.get_schema(target.original.schema_id)
for cst_id in original_schema.get_dependant([target.original_id]):
if cst_id not in ignore_parents:
inheritor_id = self.cache.get_inheritor(cst_id, operation_id)
@ -270,6 +297,9 @@ class PropagationEngine:
full_cst = Constituenta.objects.get(pk=target.original_id)
cst_mapping = create_dependant_mapping(original_schema, [full_cst])
self.inherit_cst(operation_id, original_schema, [full_cst], cst_mapping)
if original_attributions:
self.inherit_attributions(operation_id, original_attributions)
new_original_id = self.cache.get_inheritor(target.original_id, operation_id)
assert new_original_id is not None
new_original = schema.cache.by_id[new_original_id]
@ -281,12 +311,13 @@ class PropagationEngine:
mapping = {substitution_inheritor.alias: new_original}
self._on_partial_mapping(mapping, dependant, operation_id, schema)
def _determine_insert_position(
self, prototype_id: int,
operation: Operation,
source: RSFormCached,
destination: RSFormCached
) -> int:
) -> Optional[int]:
''' Determine insert_after for new constituenta. '''
prototype = source.cache.by_id[prototype_id]
prototype_index = source.cache.constituents.index(prototype)
@ -295,7 +326,7 @@ class PropagationEngine:
prev_cst = source.cache.constituents[prototype_index - 1]
inherited_prev_id = self.cache.get_successor(prev_cst.pk, operation.pk)
if inherited_prev_id is None:
return INSERT_LAST
return None
prev_cst = destination.cache.by_id[inherited_prev_id]
prev_index = destination.cache.constituents.index(prev_cst)
return prev_index + 1
@ -318,8 +349,7 @@ class PropagationEngine:
return result
def _transform_substitutions(
self,
target: CstSubstitution,
self, target: CstSubstitution,
operation: int,
schema: RSFormCached
) -> CstSubstitution:
@ -359,8 +389,7 @@ class PropagationEngine:
return result
def _on_partial_mapping(
self,
mapping: CstMapping,
self, mapping: CstMapping,
target: list[int],
operation: int,
schema: RSFormCached
@ -374,7 +403,7 @@ class PropagationEngine:
self.cache.ensure_loaded_subs()
for child_id in children:
child_operation = self.cache.operation_by_id[child_id]
child_schema = self.cache.get_schema(child_operation)
child_schema = self.cache.get_result(child_operation)
if child_schema is None:
continue
new_mapping = self._transform_mapping(mapping, child_operation, child_schema)

View File

@ -1,101 +1,110 @@
''' Models: Change propagation facade - managing all changes in OSS. '''
from typing import Optional
from apps.library.models import LibraryItem, LibraryItemType
from apps.library.models import LibraryItem
from apps.rsform.models import Attribution, Constituenta, CstType, RSFormCached
from .OperationSchemaCached import CstSubstitution, OperationSchemaCached
from .PropagationContext import PropagationContext
def _get_oss_hosts(schemaID: int) -> list[LibraryItem]:
''' Get all hosts for LibraryItem. '''
return list(LibraryItem.objects.filter(operations__result_id=schemaID).only('pk').distinct())
def _get_oss_hosts(schemaID: int) -> list[int]:
''' Get all hosts for schema. '''
return list(LibraryItem.objects.filter(operations__result_id=schemaID).distinct().values_list('pk', flat=True))
class PropagationFacade:
''' Change propagation API. '''
@staticmethod
def after_create_cst(source: RSFormCached, new_cst: list[Constituenta],
def __init__(self) -> None:
self._context = PropagationContext()
self._oss: dict[int, OperationSchemaCached] = {}
def get_oss(self, schemaID: int) -> OperationSchemaCached:
''' Get OperationSchemaCached for schemaID. '''
if schemaID not in self._oss:
self._oss[schemaID] = OperationSchemaCached(schemaID, self._context)
return self._oss[schemaID]
def get_schema(self, schemaID: int) -> RSFormCached:
''' Get RSFormCached for schemaID. '''
return self._context.get_schema(schemaID)
def after_create_cst(self, new_cst: list[Constituenta],
exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions when new constituenta is created. '''
hosts = _get_oss_hosts(source.model.pk)
if not new_cst:
return
source = new_cst[0].schema_id
hosts = _get_oss_hosts(source)
for host in hosts:
if exclude is None or host.pk not in exclude:
OperationSchemaCached(host).after_create_cst(source, new_cst)
if exclude is None or host not in exclude:
self.get_oss(host).after_create_cst(source, new_cst)
@staticmethod
def after_change_cst_type(sourceID: int, target: int, new_type: CstType,
def after_change_cst_type(self, sourceID: int, target: int, new_type: CstType,
exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions when constituenta type is changed. '''
hosts = _get_oss_hosts(sourceID)
for host in hosts:
if exclude is None or host.pk not in exclude:
OperationSchemaCached(host).after_change_cst_type(sourceID, target, new_type)
if exclude is None or host not in exclude:
self.get_oss(host).after_change_cst_type(sourceID, target, new_type)
@staticmethod
# pylint: disable=too-many-arguments, too-many-positional-arguments
def after_update_cst(
source: RSFormCached,
target: int,
data: dict,
old_data: dict,
self, sourceID: int, target: int,
data: dict, old_data: dict,
exclude: Optional[list[int]] = None
) -> None:
''' Trigger cascade resolutions when constituenta data is changed. '''
hosts = _get_oss_hosts(source.model.pk)
hosts = _get_oss_hosts(sourceID)
for host in hosts:
if exclude is None or host.pk not in exclude:
OperationSchemaCached(host).after_update_cst(source, target, data, old_data)
if exclude is None or host not in exclude:
self.get_oss(host).after_update_cst(sourceID, target, data, old_data)
@staticmethod
def before_delete_cst(sourceID: int, target: list[int],
def before_delete_cst(self, sourceID: int, target: list[int],
exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions before constituents are deleted. '''
hosts = _get_oss_hosts(sourceID)
for host in hosts:
if exclude is None or host.pk not in exclude:
OperationSchemaCached(host).before_delete_cst(sourceID, target)
if exclude is None or host not in exclude:
self.get_oss(host).before_delete_cst(sourceID, target)
@staticmethod
def before_substitute(sourceID: int, substitutions: CstSubstitution,
def before_substitute(self, sourceID: int, substitutions: CstSubstitution,
exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions before constituents are substituted. '''
if not substitutions:
return
hosts = _get_oss_hosts(sourceID)
for host in hosts:
if exclude is None or host.pk not in exclude:
OperationSchemaCached(host).before_substitute(sourceID, substitutions)
if exclude is None or host not in exclude:
self.get_oss(host).before_substitute(sourceID, substitutions)
@staticmethod
def before_delete_schema(item: LibraryItem, exclude: Optional[list[int]] = None) -> None:
def before_delete_schema(self, target: int, exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions before schema is deleted. '''
if item.item_type != LibraryItemType.RSFORM:
return
hosts = _get_oss_hosts(item.pk)
hosts = _get_oss_hosts(target)
if not hosts:
return
ids = list(Constituenta.objects.filter(schema=item).order_by('order').values_list('pk', flat=True))
ids = list(Constituenta.objects.filter(schema_id=target).order_by('order').values_list('pk', flat=True))
for host in hosts:
if exclude is None or host.pk not in exclude:
OperationSchemaCached(host).before_delete_cst(item.pk, ids)
if exclude is None or host not in exclude:
self.get_oss(host).before_delete_cst(target, ids)
del self._oss[host]
@staticmethod
def after_create_attribution(sourceID: int, associations: list[Attribution],
def after_create_attribution(self, sourceID: int,
attributions: list[Attribution],
exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions when Attribution is created. '''
hosts = _get_oss_hosts(sourceID)
for host in hosts:
if exclude is None or host.pk not in exclude:
OperationSchemaCached(host).after_create_attribution(sourceID, associations)
if exclude is None or host not in exclude:
self.get_oss(host).after_create_attribution(sourceID, attributions)
@staticmethod
def before_delete_attribution(sourceID: int,
associations: list[Attribution],
def before_delete_attribution(self, sourceID: int,
attributions: list[Attribution],
exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions before Attribution is deleted. '''
hosts = _get_oss_hosts(sourceID)
for host in hosts:
if exclude is None or host.pk not in exclude:
OperationSchemaCached(host).before_delete_attribution(sourceID, associations)
if exclude is None or host not in exclude:
self.get_oss(host).before_delete_attribution(sourceID, attributions)

View File

@ -7,6 +7,7 @@ from .Layout import Layout
from .Operation import Operation, OperationType
from .OperationSchema import OperationSchema
from .OperationSchemaCached import OperationSchemaCached
from .PropagationContext import PropagationContext
from .PropagationFacade import PropagationFacade
from .Replica import Replica
from .Substitution import Substitution

View File

@ -602,15 +602,13 @@ class RelocateConstituentsSerializer(StrictSerializer):
items = PKField(
many=True,
allow_empty=False,
queryset=Constituenta.objects.all()
queryset=Constituenta.objects.all().only('schema_id')
)
def validate(self, attrs):
attrs['destination'] = attrs['destination'].id
attrs['source'] = attrs['items'][0].schema_id
# TODO: check permissions for editing source and destination
if attrs['source'] == attrs['destination']:
raise serializers.ValidationError({
'destination': msg.sourceEqualDestination()
@ -625,15 +623,6 @@ class RelocateConstituentsSerializer(StrictSerializer):
'items': msg.RelocatingInherited()
})
oss = LibraryItem.objects \
.filter(operations__result_id=attrs['destination']) \
.filter(operations__result_id=attrs['source']).only('id')
if not oss.exists():
raise serializers.ValidationError({
'destination': msg.schemasNotConnected()
})
attrs['oss'] = oss[0].pk
if Argument.objects.filter(
operation__result_id=attrs['destination'],
argument__result_id=attrs['source']

View File

@ -1,6 +1,6 @@
''' Testing API: Change attributes of OSS and RSForms. '''
from apps.library.models import AccessPolicy, Editor, LibraryItem, LocationHead
from apps.oss.models import Operation, OperationSchema, OperationType
from apps.oss.models import OperationSchema, OperationType
from apps.rsform.models import RSForm
from apps.users.models import User
from shared.EndpointTester import EndpointTester, decl_endpoint

View File

@ -1,7 +1,7 @@
''' Testing API: Change constituents in OSS. '''
from apps.oss.models import OperationSchema, OperationType
from apps.rsform.models import Constituenta, CstType, RSForm
from apps.oss.models import OperationSchema, OperationType, PropagationFacade
from apps.rsform.models import Attribution, Constituenta, CstType, RSForm
from shared.EndpointTester import EndpointTester, decl_endpoint
@ -95,14 +95,14 @@ class TestChangeConstituents(EndpointTester):
])
@decl_endpoint('/api/rsforms/{schema}/create-cst', method='post')
@decl_endpoint('/api/rsforms/{item}/create-cst', method='post')
def test_create_constituenta(self):
data = {
'alias': 'X3',
'cst_type': CstType.BASE,
'definition_formal': 'X4 = X5'
}
response = self.executeCreated(data, schema=self.ks1.model.pk)
response = self.executeCreated(data, item=self.ks1.model.pk)
new_cst = Constituenta.objects.get(pk=response.data['new_cst']['id'])
inherited_cst = Constituenta.objects.get(as_child__parent_id=new_cst.pk)
self.assertEqual(self.ks1.constituentsQ().count(), 3)
@ -112,7 +112,7 @@ class TestChangeConstituents(EndpointTester):
self.assertEqual(inherited_cst.definition_formal, 'X1 = X2')
@decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch')
@decl_endpoint('/api/rsforms/{item}/update-cst', method='patch')
def test_update_constituenta(self):
d2 = self.ks3.insert_last('D2', cst_type=CstType.TERM, definition_raw='@{X1|sing,nomn}')
data = {
@ -125,7 +125,7 @@ class TestChangeConstituents(EndpointTester):
'crucial': True,
}
}
response = self.executeOK(data, schema=self.ks1.model.pk)
self.executeOK(data, item=self.ks1.model.pk)
self.ks1X1.refresh_from_db()
d2.refresh_from_db()
inherited_cst = Constituenta.objects.get(as_child__parent_id=self.ks1X1.pk)
@ -142,10 +142,10 @@ class TestChangeConstituents(EndpointTester):
self.assertEqual(inherited_cst.definition_raw, r'@{X2|sing,datv}')
@decl_endpoint('/api/rsforms/{schema}/delete-multiple-cst', method='patch')
@decl_endpoint('/api/rsforms/{item}/delete-multiple-cst', method='patch')
def test_delete_constituenta(self):
data = {'items': [self.ks2X1.pk]}
response = self.executeOK(data, schema=self.ks2.model.pk)
self.executeOK(data, item=self.ks2.model.pk)
inherited_cst = Constituenta.objects.get(as_child__parent_id=self.ks2D1.pk)
self.ks2D1.refresh_from_db()
self.assertEqual(self.ks2.constituentsQ().count(), 1)
@ -154,17 +154,164 @@ class TestChangeConstituents(EndpointTester):
self.assertEqual(inherited_cst.definition_formal, r'DEL\DEL')
@decl_endpoint('/api/rsforms/{schema}/substitute', method='patch')
@decl_endpoint('/api/rsforms/{item}/substitute', method='patch')
def test_substitute(self):
d2 = self.ks3.insert_last('D2', cst_type=CstType.TERM, definition_formal=r'X1\X2\X3')
data = {'substitutions': [{
'original': self.ks1X1.pk,
'substitution': self.ks1X2.pk
}]}
self.executeOK(data, schema=self.ks1.model.pk)
self.executeOK(data, item=self.ks1.model.pk)
self.ks1X2.refresh_from_db()
d2.refresh_from_db()
self.assertEqual(self.ks1.constituentsQ().count(), 1)
self.assertEqual(self.ks3.constituentsQ().count(), 4)
self.assertEqual(self.ks1X2.order, 0)
self.assertEqual(d2.definition_formal, r'X2\X2\X3')
@decl_endpoint('/api/rsforms/{item}/create-attribution', method='post')
def test_create_attribution(self):
x1_child = Constituenta.objects.get(as_child__parent_id=self.ks1X1.pk)
x2_child = Constituenta.objects.get(as_child__parent_id=self.ks1X2.pk)
data = {'container': x1_child.pk, 'attribute': x2_child.pk}
self.executeBadData(data, item=self.ks3.model.pk)
data = {'container': self.ks1X1.pk, 'attribute': self.ks1X2.pk}
self.executeCreated(data, item=self.ks1.model.pk)
self.assertTrue(Attribution.objects.filter(container=x1_child, attribute=x2_child).exists())
ks2x1_child = Constituenta.objects.get(as_child__parent_id=self.ks2X1.pk)
data = {'container': x1_child.pk, 'attribute': ks2x1_child.pk}
self.executeCreated(data, item=self.ks3.model.pk)
self.assertTrue(Attribution.objects.filter(container=x1_child, attribute=ks2x1_child).exists())
@decl_endpoint('/api/rsforms/{item}/create-attribution', method='post')
def test_create_attribution_substitution(self):
self.operation3.result.delete()
self.owned.set_substitutions(self.operation3.pk, [{
'original': self.ks1X1,
'substitution': self.ks2X1
}])
self.owned.execute_operation(self.operation3)
self.operation3.refresh_from_db()
self.ks3 = RSForm(self.operation3.result)
ks2x1_child = Constituenta.objects.get(as_child__parent_id=self.ks2X1.pk)
ks1x2_child = Constituenta.objects.get(as_child__parent_id=self.ks1X2.pk)
ks2d1_child = Constituenta.objects.get(as_child__parent_id=self.ks2D1.pk)
data = {'container': ks1x2_child.pk, 'attribute': ks2x1_child.pk}
self.executeBadData(data, item=self.ks3.model.pk)
data = {'container': ks2d1_child.pk, 'attribute': ks2x1_child.pk}
self.executeBadData(data, item=self.ks3.model.pk)
data = {'container': self.ks1X1.pk, 'attribute': self.ks1X2.pk}
self.executeCreated(data, item=self.ks1.model.pk)
self.assertTrue(Attribution.objects.filter(container=self.ks1X1, attribute=self.ks1X2).exists())
self.assertTrue(Attribution.objects.filter(container=ks2x1_child, attribute=ks1x2_child).exists())
self.executeBadData(data, item=self.ks1.model.pk)
@decl_endpoint('/api/rsforms/{item}/delete-attribution', method='patch')
def test_delete_attribution(self):
attr = Attribution.objects.create(container=self.ks1X1, attribute=self.ks1X2)
PropagationFacade().after_create_attribution(self.ks1.model.pk, [attr])
x1_child = Constituenta.objects.get(as_child__parent_id=self.ks1X1.pk)
x2_child = Constituenta.objects.get(as_child__parent_id=self.ks1X2.pk)
self.assertTrue(Attribution.objects.filter(container=x1_child, attribute=x2_child).exists())
data = {'container': x1_child.pk, 'attribute': x2_child.pk}
self.executeBadData(data, item=self.ks3.model.pk)
data = {'container': self.ks1X1.pk, 'attribute': self.ks1X2.pk}
self.executeOK(data, item=self.ks1.model.pk)
self.assertFalse(Attribution.objects.filter(container=x1_child, attribute=x2_child).exists())
self.assertFalse(Attribution.objects.filter(container=self.ks1X1, attribute=self.ks1X2).exists())
self.executeBadData(data, item=self.ks3.model.pk)
@decl_endpoint('/api/rsforms/{item}/delete-attribution', method='patch')
def test_delete_attribution_diamond_right(self):
Attribution.objects.create(container=self.ks1X1, attribute=self.ks1X2)
Attribution.objects.create(container=self.ks2X1, attribute=self.ks2D1)
self.operation3.result.delete()
self.owned.set_substitutions(self.operation3.pk, [{
'original': self.ks1X1,
'substitution': self.ks2X1
}, {
'original': self.ks1X2,
'substitution': self.ks2D1
}])
self.owned.execute_operation(self.operation3)
self.operation3.refresh_from_db()
self.ks3 = RSForm(self.operation3.result)
x1_child = Constituenta.objects.get(as_child__parent_id=self.ks2X1.pk)
x2_child = Constituenta.objects.get(as_child__parent_id=self.ks2D1.pk)
self.assertTrue(Attribution.objects.filter(container=x1_child, attribute=x2_child).exists())
data = {'container': self.ks2X1.pk, 'attribute': self.ks2D1.pk}
self.executeOK(data, item=self.ks2.model.pk)
self.assertFalse(Attribution.objects.filter(container=self.ks2X1, attribute=self.ks2D1).exists())
self.assertTrue(Attribution.objects.filter(container=self.ks1X1, attribute=self.ks1X2).exists())
self.assertTrue(Attribution.objects.filter(container=x1_child, attribute=x2_child).exists())
data = {'container': self.ks1X1.pk, 'attribute': self.ks1X2.pk}
self.executeOK(data, item=self.ks1.model.pk)
self.assertFalse(Attribution.objects.filter(container=self.ks1X1, attribute=self.ks1X2).exists())
self.assertFalse(Attribution.objects.filter(container=x1_child, attribute=x2_child).exists())
@decl_endpoint('/api/rsforms/{item}/delete-attribution', method='patch')
def test_delete_attribution_diamond_left(self):
Attribution.objects.create(container=self.ks1X1, attribute=self.ks1X2)
Attribution.objects.create(container=self.ks2X1, attribute=self.ks2D1)
self.operation3.result.delete()
self.owned.set_substitutions(self.operation3.pk, [{
'original': self.ks1X1,
'substitution': self.ks2X1
}, {
'original': self.ks1X2,
'substitution': self.ks2D1
}])
self.owned.execute_operation(self.operation3)
self.operation3.refresh_from_db()
self.ks3 = RSForm(self.operation3.result)
x1_child = Constituenta.objects.get(as_child__parent_id=self.ks2X1.pk)
x2_child = Constituenta.objects.get(as_child__parent_id=self.ks2D1.pk)
self.assertTrue(Attribution.objects.filter(container=x1_child, attribute=x2_child).exists())
data = {'container': self.ks1X1.pk, 'attribute': self.ks1X2.pk}
self.executeOK(data, item=self.ks1.model.pk)
self.assertTrue(Attribution.objects.filter(container=self.ks2X1, attribute=self.ks2D1).exists())
self.assertFalse(Attribution.objects.filter(container=self.ks1X1, attribute=self.ks1X2).exists())
self.assertTrue(Attribution.objects.filter(container=x1_child, attribute=x2_child).exists())
data = {'container': self.ks2X1.pk, 'attribute': self.ks2D1.pk}
self.executeOK(data, item=self.ks2.model.pk)
self.assertFalse(Attribution.objects.filter(container=self.ks2X1, attribute=self.ks2D1).exists())
self.assertFalse(Attribution.objects.filter(container=self.ks1X1, attribute=self.ks1X2).exists())
self.assertFalse(Attribution.objects.filter(container=x1_child, attribute=x2_child).exists())
@decl_endpoint('/api/rsforms/{item}/clear-attributions', method='patch')
def test_clear_attributions(self):
Attribution.objects.create(container=self.ks1X1, attribute=self.ks1X2)
self.operation3.result.delete()
self.owned.execute_operation(self.operation3)
self.operation3.refresh_from_db()
self.ks3 = RSForm(self.operation3.result)
ks1x1_child = Constituenta.objects.get(as_child__parent_id=self.ks1X1.pk)
ks1x2_child = Constituenta.objects.get(as_child__parent_id=self.ks1X2.pk)
ks2x1_child = Constituenta.objects.get(as_child__parent_id=self.ks2X1.pk)
Attribution.objects.create(container=ks1x1_child, attribute=ks2x1_child)
data = {'target': ks1x1_child.pk}
self.executeOK(data, item=self.ks3.model.pk)
self.assertTrue(Attribution.objects.filter(container=self.ks1X1, attribute=self.ks1X2).exists())
self.assertTrue(Attribution.objects.filter(container=ks1x1_child, attribute=ks1x2_child).exists())
self.assertFalse(Attribution.objects.filter(container=ks1x1_child, attribute=ks2x1_child).exists())

View File

@ -1,7 +1,7 @@
''' Testing API: Change substitutions in OSS. '''
from apps.oss.models import OperationSchema, OperationType
from apps.rsform.models import Constituenta, CstType, RSForm
from apps.rsform.models import Attribution, Constituenta, RSForm
from shared.EndpointTester import EndpointTester, decl_endpoint
@ -25,6 +25,7 @@ class TestChangeOperations(EndpointTester):
self.ks1X1 = self.ks1.insert_last('X1', convention='KS1X1')
self.ks1X2 = self.ks1.insert_last('X2', convention='KS1X2')
self.ks1D1 = self.ks1.insert_last('D1', definition_formal='X1 X2', convention='KS1D1')
Attribution.objects.create(container=self.ks1X1, attribute=self.ks1X2)
self.ks2 = RSForm.create(
alias='KS2',
@ -38,6 +39,7 @@ class TestChangeOperations(EndpointTester):
definition_formal=r'X1',
convention='KS2S1'
)
Attribution.objects.create(container=self.ks2S1, attribute=self.ks2X1)
self.ks3 = RSForm.create(
alias='KS3',
@ -80,6 +82,7 @@ class TestChangeOperations(EndpointTester):
self.operation4.refresh_from_db()
self.ks4 = RSForm(self.operation4.result)
self.ks4X1 = Constituenta.objects.get(as_child__parent_id=self.ks1X2.pk)
self.ks4X2 = Constituenta.objects.get(as_child__parent_id=self.ks2X1.pk)
self.ks4S1 = Constituenta.objects.get(as_child__parent_id=self.ks2S1.pk)
self.ks4D1 = Constituenta.objects.get(as_child__parent_id=self.ks1D1.pk)
self.ks4D2 = self.ks4.insert_last(
@ -87,6 +90,7 @@ class TestChangeOperations(EndpointTester):
definition_formal=r'X1 X2 X3 S1 D1',
convention='KS4D2'
)
Attribution.objects.create(container=self.ks4S1, attribute=self.ks4D2)
self.operation5 = self.owned.create_operation(
alias='5',
@ -179,8 +183,8 @@ class TestChangeOperations(EndpointTester):
title='Test6',
owner=self.user
)
ks6X1 = ks6.insert_last('X1', convention='KS6X1')
ks6X2 = ks6.insert_last('X2', convention='KS6X2')
ks6.insert_last('X1', convention='KS6X1')
ks6.insert_last('X2', convention='KS6X2')
ks6D1 = ks6.insert_last('D1', definition_formal='X1 X2', convention='KS6D1')
data = {
@ -330,6 +334,35 @@ class TestChangeOperations(EndpointTester):
self.assertEqual(self.ks5D4.definition_formal, r'D1 X2 X3 S1 D1 D2 D3')
@decl_endpoint('/api/oss/{item}/update-operation', method='patch')
def test_change_substitutions_attribution(self):
self.assertTrue(Attribution.objects.filter(container=self.ks4S1, attribute=self.ks4X1).exists())
self.assertTrue(Attribution.objects.filter(container=self.ks4S1, attribute=self.ks4X2).exists())
self.assertTrue(Attribution.objects.filter(container=self.ks4S1, attribute=self.ks4D2).exists())
data = {
'target': self.operation4.pk,
'item_data': {
'alias': 'Test4 mod',
'title': 'Test title mod',
'description': 'Comment mod'
},
'layout': self.layout_data,
'arguments': [self.operation1.pk, self.operation2.pk],
'substitutions': []
}
self.executeOK(data, item=self.owned_id)
self.assertEqual(self.operation4.getQ_substitutions().count(), 0)
x3 = Constituenta.objects.get(as_child__parent_id=self.ks1X1.pk)
self.assertTrue(Attribution.objects.filter(container=self.ks4S1, attribute=self.ks4X2).exists())
self.assertFalse(Attribution.objects.filter(container=x3, attribute=self.ks4X2).exists())
self.assertTrue(Attribution.objects.filter(container=self.ks4S1, attribute=self.ks4D2).exists())
self.assertFalse(Attribution.objects.filter(container=x3, attribute=self.ks4D2).exists())
self.assertFalse(Attribution.objects.filter(container=self.ks4S1, attribute=self.ks4X1).exists())
self.assertTrue(Attribution.objects.filter(container=x3, attribute=self.ks4X1).exists())
@decl_endpoint('/api/oss/{item}/update-operation', method='patch')
def test_change_arguments(self):
data = {
@ -388,7 +421,7 @@ class TestChangeOperations(EndpointTester):
self.assertEqual(self.ks5.constituentsQ().count(), 8)
@decl_endpoint('/api/oss/relocate-constituents', method='post')
@decl_endpoint('/api/oss/{item}/relocate-constituents', method='post')
def test_relocate_constituents_up(self):
ks1_old_count = self.ks1.constituentsQ().count()
ks4_old_count = self.ks4.constituentsQ().count()
@ -408,7 +441,7 @@ class TestChangeOperations(EndpointTester):
'items': [ks6A1.pk]
}
self.executeOK(data)
self.executeOK(data, item=self.owned_id)
ks6.model.refresh_from_db()
self.ks1.model.refresh_from_db()
self.ks4.model.refresh_from_db()
@ -418,7 +451,7 @@ class TestChangeOperations(EndpointTester):
self.assertEqual(self.ks4.constituentsQ().count(), ks4_old_count + 1)
@decl_endpoint('/api/oss/relocate-constituents', method='post')
@decl_endpoint('/api/oss/{item}/relocate-constituents', method='post')
def test_relocate_constituents_down(self):
ks1_old_count = self.ks1.constituentsQ().count()
ks4_old_count = self.ks4.constituentsQ().count()
@ -438,7 +471,7 @@ class TestChangeOperations(EndpointTester):
'items': [self.ks1X2.pk]
}
self.executeOK(data)
self.executeOK(data, item=self.owned_id)
ks6.model.refresh_from_db()
self.ks1.model.refresh_from_db()
self.ks4.model.refresh_from_db()

View File

@ -163,7 +163,7 @@ class ReferencePropagationTestCase(EndpointTester):
@decl_endpoint('/api/rsforms/{schema}/delete-multiple-cst', method='patch')
def test_delete_constituenta(self):
data = {'items': [self.ks1X1.pk]}
response = self.executeOK(data, schema=self.ks1.model.pk)
self.executeOK(data, schema=self.ks1.model.pk)
self.ks4D2.refresh_from_db()
self.ks5D4.refresh_from_db()
self.ks6D2.refresh_from_db()

View File

@ -1,7 +1,7 @@
''' Testing API: Change substitutions in OSS. '''
from apps.oss.models import OperationSchema, OperationType
from apps.rsform.models import Constituenta, CstType, RSForm
from apps.rsform.models import Constituenta, RSForm
from shared.EndpointTester import EndpointTester, decl_endpoint

View File

@ -1,6 +1,5 @@
''' Testing API: Operation Schema - blocks manipulation. '''
from apps.library.models import AccessPolicy, Editor, LibraryItem, LibraryItemType
from apps.oss.models import Operation, OperationSchema, OperationType
from apps.oss.models import OperationSchema, OperationType
from shared.EndpointTester import EndpointTester, decl_endpoint

View File

@ -1,7 +1,13 @@
''' Testing API: Operation Schema - operations manipulation. '''
from apps.library.models import AccessPolicy, Editor, LibraryItem, LibraryItemType
from apps.oss.models import Argument, Operation, OperationSchema, OperationType, Replica
from apps.rsform.models import Constituenta, RSForm
from apps.library.models import Editor, LibraryItem
from apps.oss.models import (
Argument,
Operation,
OperationSchema,
OperationType,
Replica
)
from apps.rsform.models import Attribution, RSForm
from shared.EndpointTester import EndpointTester, decl_endpoint
@ -627,6 +633,9 @@ class TestOssOperations(EndpointTester):
title='Target',
owner=self.user
)
x1 = target_ks.insert_last('X1')
x2 = target_ks.insert_last('X2')
Attribution.objects.create(container=x1, attribute=x2)
data = {
'item_data': {
'alias': 'ImportedAlias',
@ -649,24 +658,30 @@ class TestOssOperations(EndpointTester):
new_operation = next(op for op in response.data['oss']['operations'] if op['id'] == new_operation_id)
layout = response.data['oss']['layout']
operation_node = [item for item in layout if item['nodeID'] == 'o' + str(new_operation_id)][0]
schema = LibraryItem.objects.get(pk=new_operation['result'])
new_item = LibraryItem.objects.get(pk=new_operation['result'])
new_schema = RSForm(new_item)
attributions = Attribution.objects.filter(container__schema=new_item)
self.assertEqual(new_schema.constituentsQ().count(), target_ks.constituentsQ().count())
self.assertEqual(len(attributions), 1)
self.assertEqual(new_operation['alias'], data['item_data']['alias'])
self.assertEqual(new_operation['title'], data['item_data']['title'])
self.assertEqual(new_operation['description'], data['item_data']['description'])
self.assertEqual(new_operation['operation_type'], OperationType.INPUT)
self.assertEqual(schema.pk, target_ks.model.pk) # Not a clone
self.assertEqual(new_item.pk, target_ks.model.pk) # Not a clone
self.assertEqual(operation_node['x'], data['position']['x'])
self.assertEqual(operation_node['y'], data['position']['y'])
self.assertEqual(operation_node['width'], data['position']['width'])
self.assertEqual(operation_node['height'], data['position']['height'])
self.assertEqual(schema.visible, target_ks.model.visible)
self.assertEqual(schema.access_policy, target_ks.model.access_policy)
self.assertEqual(schema.location, target_ks.model.location)
self.assertEqual(new_item.visible, target_ks.model.visible)
self.assertEqual(new_item.access_policy, target_ks.model.access_policy)
self.assertEqual(new_item.location, target_ks.model.location)
@decl_endpoint('/api/oss/{item}/import-schema', method='post')
def test_import_schema_clone(self):
self.populateData()
# Use ks2 as the source RSForm
x3 = self.ks2.insert_last('X3')
Attribution.objects.create(container=self.ks2X1, attribute=x3)
data = {
'item_data': {
'alias': 'ClonedAlias',
@ -689,22 +704,26 @@ class TestOssOperations(EndpointTester):
new_operation = next(op for op in response.data['oss']['operations'] if op['id'] == new_operation_id)
layout = response.data['oss']['layout']
operation_node = [item for item in layout if item['nodeID'] == 'o' + str(new_operation_id)][0]
schema = LibraryItem.objects.get(pk=new_operation['result'])
new_item = LibraryItem.objects.get(pk=new_operation['result'])
new_schema = RSForm(new_item)
attributions = Attribution.objects.filter(container__schema=new_item)
self.assertEqual(new_schema.constituentsQ().count(), self.ks2.constituentsQ().count())
self.assertEqual(len(attributions), 1)
self.assertEqual(new_operation['alias'], data['item_data']['alias'])
self.assertEqual(new_operation['title'], data['item_data']['title'])
self.assertEqual(new_operation['description'], data['item_data']['description'])
self.assertEqual(new_operation['operation_type'], OperationType.INPUT)
self.assertNotEqual(schema.pk, self.ks2.model.pk) # Should be a clone
self.assertEqual(schema.alias, data['item_data']['alias'])
self.assertEqual(schema.title, data['item_data']['title'])
self.assertEqual(schema.description, data['item_data']['description'])
self.assertNotEqual(new_item.pk, self.ks2.model.pk) # Should be a clone
self.assertEqual(new_item.alias, data['item_data']['alias'])
self.assertEqual(new_item.title, data['item_data']['title'])
self.assertEqual(new_item.description, data['item_data']['description'])
self.assertEqual(operation_node['x'], data['position']['x'])
self.assertEqual(operation_node['y'], data['position']['y'])
self.assertEqual(operation_node['width'], data['position']['width'])
self.assertEqual(operation_node['height'], data['position']['height'])
self.assertEqual(schema.visible, False)
self.assertEqual(schema.access_policy, self.owned.model.access_policy)
self.assertEqual(schema.location, self.owned.model.location)
self.assertEqual(new_item.visible, False)
self.assertEqual(new_item.access_policy, self.owned.model.access_policy)
self.assertEqual(new_item.location, self.owned.model.location)
@decl_endpoint('/api/oss/{item}/import-schema', method='post')

View File

@ -220,8 +220,9 @@ class TestOssViewset(EndpointTester):
self.executeBadData(data)
@decl_endpoint('/api/oss/relocate-constituents', method='post')
@decl_endpoint('/api/oss/{item}/relocate-constituents', method='post')
def test_relocate_constituents(self):
self.set_params(item=self.owned_id)
self.populateData()
self.ks1X2 = self.ks1.insert_last('X2', convention='test')

View File

@ -14,7 +14,7 @@ from rest_framework.response import Response
from apps.library.models import LibraryItem, LibraryItemType
from apps.library.serializers import LibraryItemSerializer
from apps.rsform.models import Constituenta, RSFormCached
from apps.rsform.models import Constituenta
from apps.rsform.serializers import CstTargetSerializer
from shared import messages as msg
from shared import permissions
@ -23,27 +23,6 @@ from .. import models as m
from .. import serializers as s
def _create_clone(prototype: LibraryItem, operation: m.Operation, oss: LibraryItem) -> LibraryItem:
''' Create clone of prototype schema for operation. '''
clone = deepcopy(prototype)
clone.pk = None
clone.owner = oss.owner
clone.title = operation.title
clone.alias = operation.alias
clone.description = operation.description
clone.visible = False
clone.read_only = False
clone.access_policy = oss.access_policy
clone.location = oss.location
clone.save()
for cst in Constituenta.objects.filter(schema_id=prototype.pk):
cst_copy = deepcopy(cst)
cst_copy.pk = None
cst_copy.schema = clone
cst_copy.save()
return clone
@extend_schema(tags=['OSS'])
@extend_schema_view()
class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.RetrieveAPIView):
@ -312,7 +291,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
'height': position['height']
})
m.Layout.update_data(pk, layout)
m.OperationSchema.create_input(item, new_operation)
m.OperationSchema.create_input(item.pk, new_operation)
item.save(update_fields=['time_update'])
return Response(
@ -429,7 +408,22 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
if serializer.validated_data['clone_source']:
prototype: LibraryItem = serializer.validated_data['source']
new_operation.result = _create_clone(prototype, new_operation, item)
schema_clone = deepcopy(prototype)
schema_clone.pk = None
schema_clone.owner = item.owner
schema_clone.title = new_operation.title
schema_clone.alias = new_operation.alias
schema_clone.description = new_operation.description
schema_clone.visible = False
schema_clone.read_only = False
schema_clone.access_policy = item.access_policy
schema_clone.location = item.location
schema_clone.save()
m.PropagationFacade().get_schema(schema_clone.pk).insert_from(prototype.pk)
new_operation.result = schema_clone
new_operation.save(update_fields=["result"])
item.save(update_fields=['time_update'])
@ -551,7 +545,8 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
operation: m.Operation = cast(m.Operation, serializer.validated_data['target'])
with transaction.atomic():
oss = m.OperationSchemaCached(item)
propagation = m.PropagationFacade()
oss = propagation.get_oss(item.pk)
if 'layout' in serializer.validated_data:
layout = serializer.validated_data['layout']
m.Layout.update_data(pk, layout)
@ -606,12 +601,13 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
layout = [x for x in layout if x['nodeID'] != 'o' + str(operation.pk)]
with transaction.atomic():
oss = m.OperationSchemaCached(item)
propagation = m.PropagationFacade()
oss = propagation.get_oss(item.pk)
oss.delete_operation(operation.pk, serializer.validated_data['keep_constituents'])
m.Layout.update_data(pk, layout)
if old_schema is not None:
if serializer.validated_data['delete_schema']:
m.PropagationFacade.before_delete_schema(old_schema)
propagation.before_delete_schema(old_schema.pk)
old_schema.delete()
elif old_schema.is_synced(item):
old_schema.visible = True
@ -647,7 +643,8 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
layout = [x for x in layout if x['nodeID'] != 'o' + str(operation.pk)]
with transaction.atomic():
oss = m.OperationSchemaCached(item)
propagation = m.PropagationFacade()
oss = propagation.get_oss(item.pk)
m.Layout.update_data(pk, layout)
oss.delete_replica(operation.pk, keep_connections, keep_constituents)
item.save(update_fields=['time_update'])
@ -687,13 +684,13 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
with transaction.atomic():
m.Layout.update_data(pk, layout)
schema = m.OperationSchema.create_input(item, operation)
schema = m.OperationSchema.create_input(item.pk, operation)
item.save(update_fields=['time_update'])
return Response(
status=c.HTTP_200_OK,
data={
'new_schema': LibraryItemSerializer(schema.model).data,
'new_schema': LibraryItemSerializer(schema).data,
'oss': s.OperationSchemaSerializer(item).data
}
)
@ -733,7 +730,8 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
old_schema = target_operation.result
with transaction.atomic():
oss = m.OperationSchemaCached(item)
propagation = m.PropagationFacade()
oss = propagation.get_oss(item.pk)
if old_schema is not None:
if old_schema.is_synced(item):
old_schema.visible = True
@ -776,7 +774,8 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
layout = serializer.validated_data['layout']
with transaction.atomic():
oss = m.OperationSchemaCached(item)
propagation = m.PropagationFacade()
oss = propagation.get_oss(item.pk)
oss.execute_operation(operation)
m.Layout.update_data(pk, layout)
item.save(update_fields=['time_update'])
@ -830,24 +829,27 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=False, methods=['post'], url_path='relocate-constituents')
def relocate_constituents(self, request: Request) -> Response:
@action(detail=True, methods=['post'], url_path='relocate-constituents')
def relocate_constituents(self, request: Request, pk) -> Response:
''' Relocate constituents from one schema to another. '''
item = self._get_item()
serializer = s.RelocateConstituentsSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
ids = [cst.pk for cst in data['items']]
destinationID = data['destination']
with transaction.atomic():
oss = m.OperationSchemaCached(LibraryItem.objects.get(pk=data['oss']))
source = RSFormCached(LibraryItem.objects.get(pk=data['source']))
destination = RSFormCached(LibraryItem.objects.get(pk=data['destination']))
propagation = m.PropagationFacade()
oss = propagation.get_oss(item.pk)
source = propagation.get_schema(data['source'])
if data['move_down']:
oss.relocate_down(source, destination, ids)
m.PropagationFacade.before_delete_cst(data['source'], ids)
oss.relocate_down(destinationID, ids)
propagation.before_delete_cst(source.pk, ids)
source.delete_cst(ids)
else:
new_items = oss.relocate_up(source, destination, data['items'])
m.PropagationFacade.after_create_cst(destination, new_items, exclude=[oss.model.pk])
new_items = oss.relocate_up(source.pk, destinationID, ids)
propagation.after_create_cst(new_items, exclude=[oss.pk])
item.save(update_fields=['time_update'])
return Response(status=c.HTTP_200_OK)

View File

@ -8,7 +8,7 @@ ItemType = TypeVar("ItemType")
class Graph(Generic[ItemType]):
''' Directed graph. '''
def __init__(self, graph: Optional[dict[ItemType, list[ItemType]]] = None):
def __init__(self, graph: Optional[dict[ItemType, list[ItemType]]] = None) -> None:
if graph is None:
self.outputs: dict[ItemType, list[ItemType]] = {}
self.inputs: dict[ItemType, list[ItemType]] = {}

View File

@ -8,7 +8,7 @@ from .SemanticInfo import SemanticInfo
class OrderManager:
''' Ordering helper class '''
def __init__(self, schema: RSFormCached):
def __init__(self, schema: RSFormCached) -> None:
self._semantic = SemanticInfo(schema)
self._items = schema.cache.constituents
self._cst_by_ID = schema.cache.by_id
@ -22,14 +22,11 @@ class OrderManager:
self._fix_semantic_children()
self._override_order()
def _fix_topological(self) -> None:
sorted_ids = self._semantic.graph.sort_stable([cst.pk for cst in self._items])
sorted_items = [next(cst for cst in self._items if cst.pk == id) for id in sorted_ids]
self._items = sorted_items
def _fix_kernel(self) -> None:
result = [cst for cst in self._items if cst.cst_type == CstType.BASE]
result = result + [cst for cst in self._items if cst.cst_type == CstType.CONSTANT]
result = result + \
[cst for cst in self._items if result.count(cst) == 0 and len(self._semantic.graph.inputs[cst.pk]) == 0]
kernel = [
cst.pk for cst in self._items if
cst.cst_type in [CstType.STRUCTURED, CstType.AXIOM] or
@ -40,6 +37,11 @@ class OrderManager:
result = result + [cst for cst in self._items if result.count(cst) == 0]
self._items = result
def _fix_topological(self) -> None:
sorted_ids = self._semantic.graph.sort_stable([cst.pk for cst in self._items])
sorted_items = [next(cst for cst in self._items if cst.pk == id) for id in sorted_ids]
self._items = sorted_items
def _fix_semantic_children(self) -> None:
result: list[Constituenta] = []
marked: set[Constituenta] = set()

View File

@ -12,16 +12,16 @@ from shared import messages as msg
from ..graph import Graph
from .api_RSLanguage import get_type_prefix, guess_type
from .Attribution import Attribution
from .Constituenta import Constituenta, CstType, extract_entities, extract_globals
INSERT_LAST: int = -1
DELETED_ALIAS = 'DEL'
class RSForm:
''' RSForm wrapper. No caching, each mutation requires querying. '''
def __init__(self, model: LibraryItem):
def __init__(self, model: LibraryItem) -> None:
assert model.item_type == LibraryItemType.RSFORM
self.model = model
@ -271,6 +271,25 @@ class RSForm:
mapping[original.alias] = substitution.alias
deleted.append(original.pk)
replacements.append(substitution.pk)
attributions = list(Attribution.objects.filter(container__schema=self.model))
if attributions:
orig_to_sub = {original.pk: substitution.pk for original, substitution in substitutions}
orig_pks = set(orig_to_sub.keys())
for attr in attributions:
if attr.container_id not in orig_pks and attr.attribute_id not in orig_pks:
continue
container_id = orig_to_sub.get(attr.container_id)
container_id = container_id if container_id is not None else attr.container_id
attr_id = orig_to_sub.get(attr.attribute_id)
attr_id = attr_id if attr_id is not None else attr.attribute_id
if not any(a.container_id == container_id and a.attribute_id == attr_id for a in attributions):
attr.attribute_id = attr_id
attr.container_id = container_id
attr.save()
Constituenta.objects.filter(pk__in=deleted).delete()
cst_list = Constituenta.objects.filter(schema=self.model).only(
'alias', 'cst_type', 'definition_formal',

View File

@ -12,28 +12,23 @@ from apps.library.models import LibraryItem, LibraryItemType
from shared import messages as msg
from .api_RSLanguage import generate_structure, get_type_prefix, guess_type
from .Attribution import Attribution
from .Constituenta import Constituenta, CstType
from .RSForm import DELETED_ALIAS, INSERT_LAST, RSForm
from .RSForm import DELETED_ALIAS, RSForm
class RSFormCached:
''' RSForm cached. Caching allows to avoid querying for each method call. '''
def __init__(self, model: LibraryItem):
self.model = model
def __init__(self, item_id: int) -> None:
self.pk = item_id
self.cache: _RSFormCache = _RSFormCache(self)
@staticmethod
def create(**kwargs) -> 'RSFormCached':
''' Create LibraryItem via RSForm. '''
model = LibraryItem.objects.create(item_type=LibraryItemType.RSFORM, **kwargs)
return RSFormCached(model)
@staticmethod
def from_id(pk: int) -> 'RSFormCached':
''' Get LibraryItem by pk. '''
model = LibraryItem.objects.get(pk=pk)
return RSFormCached(model)
return RSFormCached(model.pk)
def get_dependant(self, target: Iterable[int]) -> set[int]:
''' Get list of constituents depending on target (only 1st degree). '''
@ -50,7 +45,7 @@ class RSFormCached:
def constituentsQ(self) -> QuerySet[Constituenta]:
''' Get QuerySet containing all constituents of current RSForm. '''
return Constituenta.objects.filter(schema=self.model)
return Constituenta.objects.filter(schema_id=self.pk)
def insert_last(
self,
@ -61,9 +56,9 @@ class RSFormCached:
''' Insert new constituenta at last position. '''
if cst_type is None:
cst_type = guess_type(alias)
position = Constituenta.objects.filter(schema=self.model).count()
position = Constituenta.objects.filter(schema_id=self.pk).count()
result = Constituenta.objects.create(
schema=self.model,
schema_id=self.pk,
order=position,
alias=alias,
cst_type=cst_type,
@ -75,14 +70,14 @@ class RSFormCached:
def create_cst(self, data: dict, insert_after: Optional[Constituenta] = None) -> Constituenta:
''' Create constituenta from data. '''
self.cache.ensure_loaded_terms()
if insert_after is not None:
if insert_after:
position = self.cache.by_id[insert_after.pk].order + 1
else:
position = len(self.cache.constituents)
RSForm.shift_positions(position, 1, self.cache.constituents)
result = Constituenta.objects.create(
schema=self.model,
schema_id=self.pk,
order=position,
alias=data['alias'],
cst_type=data['cst_type'],
@ -108,49 +103,77 @@ class RSFormCached:
RSForm.resolve_term_change(self.cache.constituents, [result.pk], self.cache.by_alias, self.cache.by_id)
return result
def insert_from(
self, sourceID: int,
items_list: Optional[list[int]] = None,
initial_mapping: Optional[dict[str, str]] = None
) -> list[tuple[Constituenta, Constituenta]]:
''' Insert copy of constituents from source schema. '''
if not items_list:
items = list(Constituenta.objects.filter(schema_id=sourceID).order_by('order'))
else:
items = list(Constituenta.objects.filter(pk__in=items_list, schema_id=sourceID).order_by('order'))
if not items:
return []
new_constituents = self.insert_copy(items=items, initial_mapping=initial_mapping)
return list(zip(items, new_constituents))
def insert_copy(
self,
items: list[Constituenta],
position: int = INSERT_LAST,
position: Optional[int] = None,
initial_mapping: Optional[dict[str, str]] = None
) -> list[Constituenta]:
''' Insert copy of target constituents updating references. '''
count = len(items)
if count == 0:
if not items:
return []
self.cache.ensure_loaded()
lastPosition = len(self.cache.constituents)
if position == INSERT_LAST:
position = lastPosition
last_position = len(self.cache.constituents)
if not position:
position = last_position
else:
position = max(0, min(position, lastPosition))
RSForm.shift_positions(position, count, self.cache.constituents)
position = max(0, min(position, last_position))
indices: dict[str, int] = {}
for (value, _) in CstType.choices:
indices[value] = -1
was_empty = last_position == 0
if not was_empty and position != last_position:
RSForm.shift_positions(position, len(items), self.cache.constituents)
mapping: dict[str, str] = initial_mapping.copy() if initial_mapping else {}
for cst in items:
if indices[cst.cst_type] == -1:
indices[cst.cst_type] = self._get_max_index(cst.cst_type)
indices[cst.cst_type] = indices[cst.cst_type] + 1
newAlias = f'{get_type_prefix(cst.cst_type)}{indices[cst.cst_type]}'
mapping[cst.alias] = newAlias
mapping_alias: dict[str, str] = initial_mapping.copy() if initial_mapping else {}
if not was_empty:
indices: dict[str, int] = {}
for (value, _) in CstType.choices:
indices[value] = self._get_max_index(value)
result = deepcopy(items)
for cst in result:
for cst in items:
indices[cst.cst_type] = indices[cst.cst_type] + 1
newAlias = f'{get_type_prefix(cst.cst_type)}{indices[cst.cst_type]}'
mapping_alias[cst.alias] = newAlias
source_ids = [cst.id for cst in items]
new_constituents = deepcopy(items)
for cst in new_constituents:
cst.pk = None
cst.schema = self.model
cst.schema_id = self.pk
cst.order = position
cst.alias = mapping[cst.alias]
cst.apply_mapping(mapping)
if mapping_alias:
cst.alias = mapping_alias[cst.alias]
cst.apply_mapping(mapping_alias)
position = position + 1
new_cst = Constituenta.objects.bulk_create(result)
self.cache.insert_multi(new_cst)
return result
new_constituents = Constituenta.objects.bulk_create(new_constituents)
mapping_id: dict[int, int] = {source_ids[i]: new_constituents[i].id for i in range(len(source_ids))}
attributions = list(Attribution.objects.filter(container__in=source_ids, attribute__in=source_ids))
for attr in attributions:
attr.pk = None
attr.container_id = mapping_id[attr.container_id]
attr.attribute_id = mapping_id[attr.attribute_id]
Attribution.objects.bulk_create(attributions)
self.cache.insert_multi(new_constituents)
return new_constituents
# pylint: disable=too-many-branches
def update_cst(self, target: int, data: dict) -> dict:
@ -233,6 +256,26 @@ class RSFormCached:
mapping[original.alias] = substitution.alias
deleted.append(original)
replacements.append(substitution.pk)
attributions = list(Attribution.objects.filter(container__schema_id=self.pk))
if attributions:
orig_to_sub = {original.pk: substitution.pk for original, substitution in substitutions}
orig_pks = set(orig_to_sub.keys())
for attr in attributions:
if attr.container_id not in orig_pks and attr.attribute_id not in orig_pks:
continue
container_id = orig_to_sub.get(attr.container_id)
container_id = container_id if container_id is not None else attr.container_id
attr_id = orig_to_sub.get(attr.attribute_id)
attr_id = attr_id if attr_id is not None else attr.attribute_id
if attr_id != container_id and not any(
a.container_id == container_id and a.attribute_id == attr_id for a in attributions):
attr.attribute_id = attr_id
attr.container_id = container_id
attr.save()
self.cache.remove_multi(deleted)
Constituenta.objects.filter(pk__in=[cst.pk for cst in deleted]).delete()
RSForm.save_order(self.cache.constituents)
@ -325,7 +368,7 @@ class RSFormCached:
prefix = get_type_prefix(cst_type)
for text in expressions:
new_item = Constituenta.objects.create(
schema=self.model,
schema_id=self.pk,
order=position,
alias=f'{prefix}{free_index}',
definition_formal=text,
@ -343,7 +386,7 @@ class RSFormCached:
cst_list: Iterable[Constituenta] = []
if not self.cache.is_loaded:
cst_list = Constituenta.objects \
.filter(schema=self.model, cst_type=cst_type) \
.filter(schema_id=self.pk, cst_type=cst_type) \
.only('alias')
else:
cst_list = [cst for cst in self.cache.constituents if cst.cst_type == cst_type]
@ -357,7 +400,7 @@ class RSFormCached:
class _RSFormCache:
''' Cache for RSForm constituents. '''
def __init__(self, schema: 'RSFormCached'):
def __init__(self, schema: 'RSFormCached') -> None:
self._schema = schema
self.constituents: list[Constituenta] = []
self.by_id: dict[int, Constituenta] = {}

View File

@ -16,7 +16,7 @@ from .RSFormCached import RSFormCached
class SemanticInfo:
''' Semantic information derived from constituents. '''
def __init__(self, schema: RSFormCached):
def __init__(self, schema: RSFormCached) -> None:
schema.cache.ensure_loaded()
self._items = schema.cache.constituents
self._cst_by_ID = schema.cache.by_id

View File

@ -3,5 +3,5 @@
from .Attribution import Attribution
from .Constituenta import Constituenta, CstType, extract_globals, replace_entities, replace_globals
from .OrderManager import OrderManager
from .RSForm import DELETED_ALIAS, INSERT_LAST, RSForm
from .RSForm import DELETED_ALIAS, RSForm
from .RSFormCached import RSFormCached

View File

@ -228,10 +228,10 @@ class RSFormSerializer(StrictModelSerializer):
'id': oss.pk,
'alias': oss.alias
})
for assoc in Attribution.objects.filter(container__schema=instance).only('container_id', 'attribute_id'):
for attrib in Attribution.objects.filter(container__schema=instance).only('container_id', 'attribute_id'):
result['attribution'].append({
'container': assoc.container_id,
'attribute': assoc.attribute_id
'container': attrib.container_id,
'attribute': attrib.attribute_id
})
return result
@ -304,9 +304,9 @@ class RSFormSerializer(StrictModelSerializer):
Attribution.objects.filter(container__schema=instance).delete()
attributions_to_create: list[Attribution] = []
for assoc in data.get('attribution', []):
old_container_id = assoc['container']
old_attribute_id = assoc['attribute']
for attrib in data.get('attribution', []):
old_container_id = attrib['container']
old_attribute_id = attrib['attribute']
container_id = id_map.get(old_container_id)
attribute_id = id_map.get(old_attribute_id)
if container_id and attribute_id:
@ -436,7 +436,7 @@ class InlineSynthesisSerializer(StrictSerializer):
''' Serializer: Inline synthesis operation input. '''
receiver = PKField(many=False, queryset=LibraryItem.objects.all().only('owner_id'))
source = PKField(many=False, queryset=LibraryItem.objects.all().only('owner_id')) # type: ignore
items = PKField(many=True, queryset=Constituenta.objects.all())
items = PKField(many=True, queryset=Constituenta.objects.all().only('schema_id'))
substitutions = serializers.ListField(
child=SubstitutionSerializerBase()
)

View File

@ -123,7 +123,7 @@ class RSFormTRSSerializer(serializers.Serializer):
result['description'] = data.get('description', '')
if 'id' in data:
result['id'] = data['id']
self.instance = RSFormCached.from_id(result['id'])
self.instance = RSFormCached(result['id'])
return result
def validate(self, attrs: dict):
@ -151,7 +151,7 @@ class RSFormTRSSerializer(serializers.Serializer):
for cst_data in validated_data['items']:
cst = Constituenta(
alias=cst_data['alias'],
schema=self.instance.model,
schema_id=self.instance.pk,
order=order,
cst_type=cst_data['cstType'],
)
@ -163,12 +163,13 @@ class RSFormTRSSerializer(serializers.Serializer):
@transaction.atomic
def update(self, instance: RSFormCached, validated_data) -> RSFormCached:
model = LibraryItem.objects.get(pk=instance.pk)
if 'alias' in validated_data:
instance.model.alias = validated_data['alias']
model.alias = validated_data['alias']
if 'title' in validated_data:
instance.model.title = validated_data['title']
model.title = validated_data['title']
if 'description' in validated_data:
instance.model.description = validated_data['description']
model.description = validated_data['description']
order = 0
prev_constituents = instance.constituentsQ()
@ -185,7 +186,7 @@ class RSFormTRSSerializer(serializers.Serializer):
else:
cst = Constituenta(
alias=cst_data['alias'],
schema=instance.model,
schema_id=instance.pk,
order=order,
cst_type=cst_data['cstType'],
)
@ -199,7 +200,7 @@ class RSFormTRSSerializer(serializers.Serializer):
prev_cst.delete()
instance.resolve_all_text()
instance.model.save()
model.save()
return instance
@staticmethod

View File

@ -12,7 +12,7 @@ from ..models import Constituenta, CstType
class PyConceptAdapter:
''' RSForm adapter for interacting with pyconcept module. '''
def __init__(self, data: Union[int, dict]):
def __init__(self, data: Union[int, dict]) -> None:
try:
if 'items' in cast(dict, data):
self.data = self._prepare_request_raw(cast(dict, data))

View File

@ -1,4 +1,4 @@
''' Testing models: api_RSForm. '''
''' Testing models: RSForm. '''
from django.forms import ValidationError
from apps.rsform.models import Constituenta, CstType, RSForm

View File

@ -1,7 +1,5 @@
''' Testing models: api_RSForm. '''
from django.forms import ValidationError
from apps.rsform.models import Constituenta, CstType, OrderManager, RSFormCached
''' Testing models: RSFormCached. '''
from apps.rsform.models import Attribution, Constituenta, CstType, OrderManager, RSFormCached
from apps.users.models import User
from shared.DBTester import DBTester
@ -24,8 +22,8 @@ class TestRSFormCached(DBTester):
self.assertFalse(schema1.constituentsQ().exists())
self.assertFalse(schema2.constituentsQ().exists())
Constituenta.objects.create(alias='X1', schema=schema1.model, order=0)
Constituenta.objects.create(alias='X2', schema=schema1.model, order=1)
Constituenta.objects.create(alias='X1', schema_id=schema1.pk, order=0)
Constituenta.objects.create(alias='X2', schema_id=schema1.pk, order=1)
self.assertTrue(schema1.constituentsQ().exists())
self.assertFalse(schema2.constituentsQ().exists())
self.assertEqual(schema1.constituentsQ().count(), 2)
@ -34,7 +32,7 @@ class TestRSFormCached(DBTester):
def test_insert_last(self):
x1 = self.schema.insert_last('X1')
self.assertEqual(x1.order, 0)
self.assertEqual(x1.schema, self.schema.model)
self.assertEqual(x1.schema_id, self.schema.pk)
def test_create_cst(self):
@ -108,6 +106,25 @@ class TestRSFormCached(DBTester):
self.assertEqual(s2.definition_raw, '@{X11|plur}')
def test_insert_from(self):
self.schema.insert_last('X2')
self.schema.insert_last('D2')
self.schema.insert_last('X3')
self.schema.insert_last(
alias='D1',
definition_formal='X2 = X3'
)
test_ks = RSFormCached.create(title='Test')
test_ks.insert_from(self.schema.pk)
items = Constituenta.objects.filter(schema_id=test_ks.pk).order_by('order')
self.assertEqual(len(items), 4)
self.assertEqual(items[0].alias, 'X2')
self.assertEqual(items[1].alias, 'D2')
self.assertEqual(items[2].alias, 'X3')
self.assertEqual(items[3].alias, 'D1')
self.assertEqual(items[3].definition_formal, 'X2 = X3')
def test_delete_cst(self):
x1 = self.schema.insert_last('X1')
x2 = self.schema.insert_last('X2')
@ -170,6 +187,24 @@ class TestRSFormCached(DBTester):
self.assertEqual(d1.definition_formal, x2.alias)
def test_substitute_attributions(self):
x1 = self.schema.insert_last(alias='X1')
x2 = self.schema.insert_last(alias='X2')
d1 = self.schema.insert_last(alias='D1')
d2 = self.schema.insert_last(alias='D2')
Attribution.objects.create(container=x1, attribute=d2)
Attribution.objects.create(container=x2, attribute=d2)
Attribution.objects.create(container=x1, attribute=x2)
Attribution.objects.create(container=x1, attribute=d1)
self.schema.substitute([(x1, x2)])
self.assertEqual(self.schema.constituentsQ().count(), 3)
self.assertEqual(Attribution.objects.filter(container__schema_id=self.schema.pk).count(), 2)
self.assertTrue(Attribution.objects.filter(container=x2, attribute=d2).exists())
self.assertTrue(Attribution.objects.filter(container=x2, attribute=d1).exists())
def test_restore_order(self):
d2 = self.schema.insert_last(
alias='D2',
@ -194,6 +229,10 @@ class TestRSFormCached(DBTester):
alias='A1',
definition_formal=r'D3=∅',
)
a2 = self.schema.insert_last(
alias='A2',
definition_formal=r'P1[S1]',
)
d3 = self.schema.insert_last(
alias='D3',
definition_formal=r'Pr2(S2)',
@ -210,6 +249,10 @@ class TestRSFormCached(DBTester):
alias='F2',
definition_formal=r'[α∈ℬ(X1)] X1\α',
)
p1 = self.schema.insert_last(
alias='P1',
definition_formal=r'[α∈ℬ(R1)] card(α)=0',
)
OrderManager(self.schema).restore_order()
x1.refresh_from_db()
@ -224,19 +267,23 @@ class TestRSFormCached(DBTester):
f1.refresh_from_db()
f2.refresh_from_db()
a1.refresh_from_db()
a2.refresh_from_db()
p1.refresh_from_db()
self.assertEqual(x1.order, 0)
self.assertEqual(x2.order, 1)
self.assertEqual(c1.order, 2)
self.assertEqual(s1.order, 3)
self.assertEqual(d1.order, 4)
self.assertEqual(s2.order, 5)
self.assertEqual(d3.order, 6)
self.assertEqual(a1.order, 7)
self.assertEqual(d4.order, 8)
self.assertEqual(d2.order, 9)
self.assertEqual(f1.order, 10)
self.assertEqual(f2.order, 11)
self.assertEqual(p1.order, 3)
self.assertEqual(s1.order, 4)
self.assertEqual(a2.order, 5)
self.assertEqual(d1.order, 6)
self.assertEqual(s2.order, 7)
self.assertEqual(d3.order, 8)
self.assertEqual(a1.order, 9)
self.assertEqual(d4.order, 10)
self.assertEqual(d2.order, 11)
self.assertEqual(f1.order, 12)
self.assertEqual(f2.order, 13)
def test_reset_aliases(self):

View File

@ -42,13 +42,13 @@ class TestAttributionsEndpoints(EndpointTester):
self.executeBadData(data, item=self.owned_id)
data = {'container': self.n1.pk, 'attribute': self.x1.pk}
self.executeBadData(data, item=self.unowned_id)
self.executeForbidden(data, item=self.unowned_id)
response = self.executeCreated(data, item=self.owned_id)
associations = response.data['attribution']
self.assertEqual(len(associations), 1)
self.assertEqual(associations[0]['container'], self.n1.pk)
self.assertEqual(associations[0]['attribute'], self.x1.pk)
attributions = response.data['attribution']
self.assertEqual(len(attributions), 1)
self.assertEqual(attributions[0]['container'], self.n1.pk)
self.assertEqual(attributions[0]['attribute'], self.x1.pk)
@decl_endpoint('/api/rsforms/{item}/create-attribution', method='post')
@ -94,7 +94,7 @@ class TestAttributionsEndpoints(EndpointTester):
attribute=self.n1
)
response = self.executeOK(data, item=self.owned_id)
associations = response.data['attribution']
self.assertEqual(len(associations), 1)
self.assertEqual(associations[0]['container'], self.n2.pk)
self.assertEqual(associations[0]['attribute'], self.n1.pk)
attributions = response.data['attribution']
self.assertEqual(len(attributions), 1)
self.assertEqual(attributions[0]['container'], self.n2.pk)
self.assertEqual(attributions[0]['attribute'], self.n1.pk)

View File

@ -238,11 +238,55 @@ class TestRSFormViewset(EndpointTester):
'substitution': d2.pk
}
]}
response = self.executeOK(data, item=self.owned_id)
self.executeOK(data, item=self.owned_id)
d3.refresh_from_db()
self.assertEqual(d3.definition_formal, r'D1 \ D2')
@decl_endpoint('/api/rsforms/{item}/substitute', method='patch')
def test_substitute_with_attributions(self):
self.set_params(item=self.owned_id)
# Create two base items
x1 = self.owned.insert_last('X1')
x2 = self.owned.insert_last('X2')
# Create two attributes to be attributions
a1 = self.owned.insert_last('A1', cst_type=CstType.BASE)
a2 = self.owned.insert_last('A2', cst_type=CstType.BASE)
# Create attributions: X1 -> A1, X2 -> A2
Attribution = self.owned.constituentsQ().model._meta.apps.get_model('rsform', 'Attribution')
Attribution.objects.create(container=x1, attribute=a1)
Attribution.objects.create(container=x2, attribute=a2)
# Substitute x1 with x2
data = {
'substitutions': [{
'original': x1.pk,
'substitution': x2.pk
}]
}
self.executeOK(data, item=self.owned_id)
# Fetch updated attributions
attributions = Attribution.objects.filter(
container__in=[x1.pk, x2.pk],
attribute__in=[a1.pk, a2.pk]
)
self.assertEqual(len(attributions), 2)
# Confirm the attribution with container originally x1 is now x2, and there are no duplicates
containers = set()
attributes = set()
for attr in attributions:
containers.add(attr.container_id)
attributes.add(attr.attribute_id)
self.assertIn(x2.pk, containers)
self.assertIn(a1.pk, attributes)
self.assertIn(a2.pk, attributes)
@decl_endpoint('/api/rsforms/{item}/create-cst', method='post')
def test_create_constituenta_data(self):
data = {

View File

@ -16,7 +16,7 @@ from rest_framework.serializers import ValidationError
from apps.library.models import AccessPolicy, LibraryItem, LibraryItemType, LocationHead
from apps.library.serializers import LibraryItemSerializer
from apps.oss.models import PropagationFacade
from apps.oss.models import Inheritance, PropagationFacade
from apps.users.models import User
from shared import messages as msg
from shared import permissions, utility
@ -49,7 +49,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
'restore_order',
'reset_aliases',
'produce_structure',
'add_attribution',
'create_attribution',
'delete_attribution',
'clear_attributions'
]:
@ -91,9 +91,10 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
insert_after = data['insert_after']
with transaction.atomic():
schema = m.RSFormCached(item)
propagation = PropagationFacade()
schema = propagation.get_schema(item.pk)
new_cst = schema.create_cst(data, insert_after)
PropagationFacade.after_create_cst(schema, [new_cst])
propagation.after_create_cst([new_cst])
item.save(update_fields=['time_update'])
return Response(
@ -125,9 +126,10 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
data = serializer.validated_data['item_data']
with transaction.atomic():
schema = m.RSFormCached(item)
propagation = PropagationFacade()
schema = propagation.get_schema(item.pk)
old_data = schema.update_cst(cst.pk, data)
PropagationFacade.after_update_cst(schema, cst.pk, data, old_data)
propagation.after_update_cst(item.pk, cst.pk, data, old_data)
if 'alias' in data and data['alias'] != cst.alias:
cst.refresh_from_db()
changed_type = 'cst_type' in data and cst.cst_type != data['cst_type']
@ -138,7 +140,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
cst.save()
schema.apply_mapping(mapping=mapping, change_aliases=False)
if changed_type:
PropagationFacade.after_change_cst_type(item.pk, cst.pk, cast(m.CstType, cst.cst_type))
propagation.after_change_cst_type(item.pk, cst.pk, cast(m.CstType, cst.cst_type))
item.save(update_fields=['time_update'])
return Response(
status=c.HTTP_200_OK,
@ -208,9 +210,10 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
)
with transaction.atomic():
schema = m.RSFormCached(item)
propagation = PropagationFacade()
schema = propagation.get_schema(item.pk)
new_cst = schema.produce_structure(cst, cst_parse)
PropagationFacade.after_create_cst(schema, new_cst)
propagation.after_create_cst(new_cst)
item.save(update_fields=['time_update'])
return Response(
status=c.HTTP_200_OK,
@ -245,7 +248,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
original = cast(m.Constituenta, substitution['original'])
replacement = cast(m.Constituenta, substitution['substitution'])
substitutions.append((original, replacement))
PropagationFacade.before_substitute(item.pk, substitutions)
PropagationFacade().before_substitute(item.pk, substitutions)
schema.substitute(substitutions)
item.save(update_fields=['time_update'])
@ -275,7 +278,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
with transaction.atomic():
schema = m.RSForm(item)
PropagationFacade.before_delete_cst(item.pk, [cst.pk for cst in cst_list])
PropagationFacade().before_delete_cst(item.pk, [cst.pk for cst in cst_list])
schema.delete_cst(cst_list)
item.save(update_fields=['time_update'])
@ -305,11 +308,16 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
attribute = serializer.validated_data['attribute']
with transaction.atomic():
new_association = m.Attribution.objects.create(
if Inheritance.check_share_origin(container.pk, attribute.pk):
raise ValidationError({
'container': msg.deleteInheritedAttribution()
})
new_attribution = m.Attribution.objects.create(
container=container,
attribute=attribute
)
PropagationFacade.after_create_attribution(item.pk, [new_association])
PropagationFacade().after_create_attribution(item.pk, [new_attribution])
item.save(update_fields=['time_update'])
return Response(
@ -318,7 +326,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
)
@extend_schema(
summary='delete Association',
summary='delete Attribution',
tags=['RSForm'],
request=s.AttributionDataSerializer,
responses={
@ -336,17 +344,22 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
serializer.is_valid(raise_exception=True)
with transaction.atomic():
target = list(m.Attribution.objects.filter(
target_query = m.Attribution.objects.filter(
container=serializer.validated_data['container'],
attribute=serializer.validated_data['attribute']
))
if not target:
)
attr = target_query.first()
if not attr:
raise ValidationError({
'container': msg.invalidAssociation()
'container': msg.missingAttribution()
})
if Inheritance.check_share_origin(request.data['container'], request.data['attribute']):
raise ValidationError({
'container': msg.deleteInheritedAttribution()
})
PropagationFacade.before_delete_attribution(item.pk, target)
m.Attribution.objects.filter(pk__in=[assoc.pk for assoc in target]).delete()
PropagationFacade().before_delete_attribution(item.pk, [attr])
attr.delete()
item.save(update_fields=['time_update'])
return Response(
@ -373,10 +386,14 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
serializer.is_valid(raise_exception=True)
with transaction.atomic():
target = list(m.Attribution.objects.filter(container=serializer.validated_data['target']))
if target:
PropagationFacade.before_delete_attribution(item.pk, target)
m.Attribution.objects.filter(pk__in=[assoc.pk for assoc in target]).delete()
attributions = list(m.Attribution.objects.filter(container=serializer.validated_data['target']))
to_delete: list[m.Attribution] = []
for attrib in attributions:
if not Inheritance.check_share_origin(attrib.container.pk, attrib.attribute.pk):
to_delete.append(attrib)
if to_delete:
PropagationFacade().before_delete_attribution(item.pk, to_delete)
m.Attribution.objects.filter(pk__in=[attrib.pk for attrib in to_delete]).delete()
item.save(update_fields=['time_update'])
return Response(
@ -456,7 +473,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
item = self._get_item()
with transaction.atomic():
m.OrderManager(m.RSFormCached(item)).restore_order()
m.OrderManager(m.RSFormCached(item.pk)).restore_order()
item.save(update_fields=['time_update'])
return Response(
@ -493,10 +510,10 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
serializer = s.RSFormTRSSerializer(data=data, context={'load_meta': load_metadata})
serializer.is_valid(raise_exception=True)
result: m.RSForm = serializer.save()
result: m.RSFormCached = serializer.save()
return Response(
status=c.HTTP_200_OK,
data=s.RSFormParseSerializer(result.model).data
data=s.RSFormParseSerializer(LibraryItem.objects.get(pk=result.pk)).data
)
@extend_schema(
@ -651,10 +668,10 @@ class TrsImportView(views.APIView):
_prepare_rsform_data(data, request, owner)
serializer = s.RSFormTRSSerializer(data=data, context={'load_meta': True})
serializer.is_valid(raise_exception=True)
schema: m.RSForm = serializer.save()
schema: m.RSFormCached = serializer.save()
return Response(
status=c.HTTP_201_CREATED,
data=LibraryItemSerializer(schema.model).data
data=LibraryItemSerializer(LibraryItem.objects.get(pk=schema.pk)).data
)
@ -687,10 +704,10 @@ def create_rsform(request: Request) -> HttpResponse:
_prepare_rsform_data(data, request, owner)
serializer_rsform = s.RSFormTRSSerializer(data=data, context={'load_meta': True})
serializer_rsform.is_valid(raise_exception=True)
schema: m.RSForm = serializer_rsform.save()
schema: m.RSFormCached = serializer_rsform.save()
return Response(
status=c.HTTP_201_CREATED,
data=LibraryItemSerializer(schema.model).data
data=LibraryItemSerializer(LibraryItem.objects.get(pk=schema.pk)).data
)
@ -731,33 +748,34 @@ def inline_synthesis(request: Request) -> HttpResponse:
serializer = s.InlineSynthesisSerializer(data=request.data, context={'user': request.user})
serializer.is_valid(raise_exception=True)
receiver = m.RSFormCached(serializer.validated_data['receiver'])
items = cast(list[m.Constituenta], serializer.validated_data['items'])
if not items:
source = cast(LibraryItem, serializer.validated_data['source'])
items = list(m.Constituenta.objects.filter(schema=source).order_by('order'))
item = cast(LibraryItem, serializer.validated_data['receiver'])
target_cst = cast(list[m.Constituenta], serializer.validated_data['items'])
source = cast(LibraryItem, serializer.validated_data['source'])
target_ids = [item.pk for item in target_cst] if target_cst else None
with transaction.atomic():
new_items = receiver.insert_copy(items)
PropagationFacade.after_create_cst(receiver, new_items)
propagation = PropagationFacade()
receiver = propagation.get_schema(item.pk)
new_items = receiver.insert_from(source.pk, target_ids)
target_ids = [item[0].pk for item in new_items]
mapping_ids = {cst.pk: new_cst for (cst, new_cst) in new_items}
propagation.after_create_cst([item[1] for item in new_items])
substitutions: list[tuple[m.Constituenta, m.Constituenta]] = []
for substitution in serializer.validated_data['substitutions']:
original = cast(m.Constituenta, substitution['original'])
replacement = cast(m.Constituenta, substitution['substitution'])
if original in items:
index = next(i for (i, cst) in enumerate(items) if cst.pk == original.pk)
original = new_items[index]
if original.pk in target_ids:
original = mapping_ids[original.pk]
else:
index = next(i for (i, cst) in enumerate(items) if cst.pk == replacement.pk)
replacement = new_items[index]
replacement = mapping_ids[replacement.pk]
substitutions.append((original, replacement))
PropagationFacade.before_substitute(receiver.model.pk, substitutions)
propagation.before_substitute(receiver.pk, substitutions)
receiver.substitute(substitutions)
receiver.model.save(update_fields=['time_update'])
item.save(update_fields=['time_update'])
return Response(
status=c.HTTP_200_OK,
data=s.RSFormParseSerializer(receiver.model).data
data=s.RSFormParseSerializer(item).data
)

View File

@ -1,21 +1,21 @@
tzdata==2025.2
Django==5.2.4
djangorestframework==3.16.0
django-cors-headers==4.7.0
django-filter==25.1
Django==5.2.7
djangorestframework==3.16.1
django-cors-headers==4.9.0
django-filter==25.2
drf-spectacular==0.28.0
drf-spectacular-sidecar==2025.7.1
drf-spectacular-sidecar==2025.10.1
coreapi==2.3.3
django-rest-passwordreset==1.5.0
cctext==0.1.4
pyconcept==0.1.12
psycopg2-binary==2.9.10
psycopg2-binary==2.9.11
gunicorn==23.0.0
djangorestframework-stubs==3.16.0
djangorestframework-stubs==3.16.5
django-extensions==4.1
django-stubs==5.2.1
mypy==1.15.0
pylint==3.3.7
coverage==7.9.2
django-stubs==5.2.7
mypy==1.18.2
pylint==4.0.2
coverage==7.11.0

View File

@ -1,14 +1,14 @@
tzdata==2025.2
Django==5.2.4
djangorestframework==3.16.0
django-cors-headers==4.7.0
django-filter==25.1
Django==5.2.7
djangorestframework==3.16.1
django-cors-headers==4.9.0
django-filter==25.2
drf-spectacular==0.28.0
drf-spectacular-sidecar==2025.7.1
drf-spectacular-sidecar==2025.10.1
coreapi==2.3.3
django-rest-passwordreset==1.5.0
cctext==0.1.4
pyconcept==0.1.12
psycopg2-binary==2.9.10
psycopg2-binary==2.9.11
gunicorn==23.0.0

View File

@ -150,8 +150,16 @@ def typificationInvalidStr():
return 'Invalid typification string'
def invalidAssociation():
return f'Ассоциация не найдена'
def missingAttribution():
return f'Атрибутирование не найдено'
def deleteInheritedAttribution():
return f'Попытка удалить наследованное атрибутирование'
def createdInheritedAttribution():
return f'Попытка установить атрибутирование между наследниками из одной КС'
def exteorFileVersionNotSupported():

View File

@ -10,6 +10,7 @@
"declaration-block-no-duplicate-properties": true,
"no-duplicate-selectors": true,
"no-empty-source": true,
"no-invalid-position-declaration": [true, { "ignoreAtRules": ["starting-style"] }],
"import-notation": null,
"at-rule-empty-line-before": null,
@ -17,6 +18,7 @@
"at-rule-no-unknown": null,
"comment-no-empty": null,
"comment-empty-line-before": null,
"custom-property-empty-line-before": null
"custom-property-empty-line-before": null,
"nesting-selector-no-missing-scoping-root": null
}
}

View File

@ -2,7 +2,6 @@ import globals from 'globals';
import typescriptPlugin from 'typescript-eslint';
import typescriptParser from '@typescript-eslint/parser';
import reactPlugin from 'eslint-plugin-react';
import reactCompilerPlugin from 'eslint-plugin-react-compiler';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
import importPlugin from 'eslint-plugin-import';
import simpleImportSort from 'eslint-plugin-simple-import-sort';
@ -36,6 +35,7 @@ const basicRules = {
};
export default [
reactHooksPlugin.configs.flat.recommended,
...typescriptPlugin.configs.recommendedTypeChecked,
...typescriptPlugin.configs.stylisticTypeChecked,
{
@ -65,7 +65,6 @@ export default [
plugins: {
'react': reactPlugin,
'react-compiler': reactCompilerPlugin,
'react-hooks': reactHooksPlugin,
'simple-import-sort': simpleImportSort,
'import': importPlugin
@ -73,8 +72,6 @@ export default [
settings: { react: { version: 'detect' } },
rules: {
...basicRules,
'react-compiler/react-compiler': 'error',
'react-refresh/only-export-components': ['off', { allowConstantExport: true }],
'simple-import-sort/imports': [
'warn',
{

File diff suppressed because it is too large Load Diff

View File

@ -10,35 +10,36 @@
"dev": "vite --host",
"build": "tsc && vite build",
"lint": "stylelint \"src/**/*.css\" && eslint . --report-unused-disable-directives --max-warnings 0",
"lintFix": "eslint . --report-unused-disable-directives --max-warnings 1 --fix",
"lintFix": "eslint . --report-unused-disable-directives --max-warnings 0 --fix",
"preview": "vite preview --port 3000"
},
"dependencies": {
"@dagrejs/dagre": "^1.1.5",
"@dagrejs/dagre": "^1.1.8",
"@hookform/resolvers": "^5.2.2",
"@lezer/lr": "^1.4.2",
"@lezer/lr": "^1.4.3",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@tanstack/react-query": "^5.90.2",
"@tanstack/react-query": "^5.90.7",
"@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/react-table": "^8.21.3",
"@uiw/codemirror-themes": "^4.25.2",
"@uiw/react-codemirror": "^4.25.2",
"axios": "^1.12.2",
"@uiw/codemirror-themes": "^4.25.3",
"@uiw/react-codemirror": "^4.25.3",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dompurify": "^3.3.0",
"global": "^4.4.0",
"js-file-download": "^0.4.12",
"lucide-react": "^0.545.0",
"lucide-react": "^0.553.0",
"qrcode.react": "^4.2.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.65.0",
"react-hook-form": "^7.66.0",
"react-icons": "^5.5.0",
"react-intl": "^7.1.14",
"react-router": "^7.9.4",
"react-router": "^7.9.5",
"react-scan": "^0.4.3",
"react-tabs": "^6.1.0",
"react-toastify": "^11.0.5",
@ -46,41 +47,40 @@
"react-zoom-pan-pinch": "^3.7.0",
"reactflow": "^11.11.4",
"tailwind-merge": "^3.3.1",
"tw-animate-css": "^1.3.7",
"tw-animate-css": "1.3.7",
"use-debounce": "^10.0.6",
"zod": "^4.1.12",
"zustand": "^5.0.8"
},
"devDependencies": {
"@lezer/generator": "^1.8.0",
"@playwright/test": "^1.56.0",
"@tailwindcss/vite": "^4.1.14",
"@playwright/test": "^1.56.1",
"@tailwindcss/vite": "^4.1.17",
"@types/jest": "^30.0.0",
"@types/node": "^24.7.2",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.1",
"@types/react-dom": "^19.2.2",
"@typescript-eslint/eslint-plugin": "^8.0.1",
"@typescript-eslint/parser": "^8.0.1",
"@vitejs/plugin-react": "^5.0.4",
"@vitejs/plugin-react": "^5.1.0",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.37.0",
"eslint": "^9.39.1",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-playwright": "^2.2.2",
"eslint-plugin-playwright": "^2.3.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.1.0-rc.1",
"eslint-plugin-react-hooks": "^7.0.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
"globals": "^16.4.0",
"globals": "^16.5.0",
"jest": "^30.2.0",
"stylelint": "^16.25.0",
"stylelint-config-recommended": "^16.0.0",
"stylelint-config-standard": "^38.0.0",
"stylelint-config-recommended": "^17.0.0",
"stylelint-config-standard": "^39.0.1",
"stylelint-config-tailwindcss": "^1.0.0",
"tailwindcss": "^4.0.7",
"ts-jest": "^29.4.5",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.0",
"vite": "^7.1.9"
"typescript-eslint": "^8.46.3",
"vite": "^7.2.2"
},
"jest": {
"preset": "ts-jest",

View File

@ -49,11 +49,7 @@ export function ApplicationLayout() {
<Navigation />
<div
className='overflow-x-auto max-w-[100dvw]'
style={{ maxHeight: viewportHeight }}
inert={activeDialog !== null}
>
<div className='overflow-x-auto max-w-dvw' style={{ maxHeight: viewportHeight }} inert={activeDialog !== null}>
<main className='cc-scroll-y overflow-y-auto' style={{ minHeight: mainHeight }}>
<GlobalLoader />
<MutationErrors />

View File

@ -1,17 +1,36 @@
'use client';
import { useEffect } from 'react';
import { useNavigate, useRouteError } from 'react-router';
import { Button } from '@/components/control';
import { InfoError } from '@/components/info-error';
import { isStaleBundleError } from '@/utils/utils';
export function ErrorFallback() {
const error = useRouteError();
const router = useNavigate();
useEffect(() => {
if (isStaleBundleError(error)) {
console.warn('Detected stale bundle — reloading...');
window.location.reload();
}
}, [error]);
function resetErrorBoundary() {
Promise.resolve(router('/')).catch(console.error);
}
if (isStaleBundleError(error)) {
return (
<div className='flex flex-col gap-3 my-3 items-center antialiased' role='alert'>
<h1 className='my-2'>Обновление страницы...</h1>
<p>Обнаружена устаревшая версия приложения. Перезагрузка страницы...</p>
</div>
);
}
return (
<div className='flex flex-col gap-3 my-3 items-center antialiased' role='alert'>
<h1 className='my-2'>Что-то пошло не так!</h1>

View File

@ -10,7 +10,7 @@ export function GlobalProviders({ children }: React.PropsWithChildren) {
<IntlProvider locale='ru' defaultLocale='ru'>
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools initialIsOpen={false} />
{import.meta.env.DEV ? <ReactQueryDevtools initialIsOpen={false} /> : null}
{children}
</QueryClientProvider>

View File

@ -10,7 +10,7 @@ export const GlobalTooltips = () => {
layer='z-topmost'
place='bottom-start'
offset={24}
className='max-w-80 break-words rounded-lg! select-none'
className='max-w-80 wrap-break-word rounded-lg! select-none'
/>
<Tooltip
float

View File

@ -11,7 +11,7 @@ export function Logo() {
<img
alt=''
aria-hidden
className='max-h-7 w-fit max-w-46'
className='max-h-7 w-fit max-w-46 cursor-pointer'
src={size.isSmall ? '/logo_sign.svg' : !darkMode ? '/logo_full.svg' : '/logo_full_dark.svg'}
/>
);

View File

@ -24,7 +24,7 @@ export function NavigationButton({ icon, title, hideTitle, className, style, onC
style={style}
>
{icon ? icon : null}
{text ? <span className='hidden md:inline'>{text}</span> : null}
{text ? <span className='hidden lg:inline'>{text}</span> : null}
</button>
);
}

View File

@ -2,6 +2,8 @@
import clsx from 'clsx';
import { useAIStore } from '@/features/ai/stores/ai-context';
import { IconLibrary2, IconManuals, IconNewItem2 } from '@/components/icons';
import { useWindowSize } from '@/hooks/use-window-size';
import { useAppLayoutStore } from '@/stores/app-layout';
@ -14,6 +16,7 @@ import { MenuAI } from './menu-ai';
import { MenuUser } from './menu-user';
import { NavigationButton } from './navigation-button';
import { useConceptNavigation } from './navigation-context';
import { SchemaTitle } from './schema-title';
import { ToggleNavigation } from './toggle-navigation';
export function Navigation() {
@ -22,6 +25,10 @@ export function Navigation() {
const noNavigationAnimation = useAppLayoutStore(state => state.noNavigationAnimation);
const activeDialog = useDialogsStore(state => state.active);
const currentSchema = useAIStore(state => state.currentSchema);
const currentOSS = useAIStore(state => state.currentOSS);
const schemaTitle = currentSchema?.title || currentOSS?.title;
const navigateHome = (event: React.MouseEvent<Element>) =>
push({ path: urls.home, newTab: event.ctrlKey || event.metaKey });
const navigateLibrary = (event: React.MouseEvent<Element>) =>
@ -36,15 +43,16 @@ export function Navigation() {
<ToggleNavigation />
<div
className={clsx(
'pl-2 sm:pr-4 h-12 flex cc-shadow-border',
'pl-2 sm:pr-4 h-12 flex gap-2 justify-between cc-shadow-border',
'transition-[max-height,translate] ease-bezier duration-move',
noNavigationAnimation ? '-translate-y-6 max-h-0' : 'max-h-12'
)}
>
<div className='flex items-center mr-auto cursor-pointer' onClick={!size.isSmall ? navigateHome : undefined}>
<div className='flex items-center shrink-0' onClick={!size.isSmall ? navigateHome : undefined}>
<Logo />
</div>
<div className='flex gap-2 items-center pr-2'>
{schemaTitle ? <SchemaTitle isRSForm={!!currentSchema} title={schemaTitle} /> : null}
<div className='flex gap-2 items-center pr-2 shrink-0'>
<NavigationButton text='Новая схема' icon={<IconNewItem2 size='1.5rem' />} onClick={navigateCreateNew} />
<NavigationButton text='Библиотека' icon={<IconLibrary2 size='1.5rem' />} onClick={navigateLibrary} />
<NavigationButton text='Справка' icon={<IconManuals size='1.5rem' />} onClick={navigateHelp} />

View File

@ -0,0 +1,28 @@
import clsx from 'clsx';
import { IconOSS, IconRSForm } from '@/components/icons';
import { globalIDs } from '@/utils/constants';
interface SchemaTitleProps {
isRSForm: boolean;
title: string;
}
export function SchemaTitle({ isRSForm, title }: SchemaTitleProps) {
return (
<div
tabIndex={-1}
className={clsx(
'min-w-0 overflow-hidden max-w-fit',
'flex flex-1 items-center gap-2',
'text-md text-muted-foreground pointer-events-auto'
)}
aria-label='Название схемы'
data-tooltip-id={globalIDs.tooltip}
data-tooltip-content={title}
>
{isRSForm ? <IconRSForm size='1.5rem' /> : <IconOSS size='1.5rem' />}
<span className='pt-0.5 font-medium truncate'>{title}</span>
</div>
);
}

View File

@ -112,7 +112,7 @@ function parseOssURL(id: string | undefined) {
function fallbackLoader() {
return (
<div className='flex justify-center items-center h-[100dvh]'>
<div className='flex justify-center items-center h-dvh'>
<Loader scale={6} />
</div>
);

View File

@ -27,6 +27,11 @@ axiosInstance.interceptors.request.use(config => {
.split('; ')
.find(row => row.startsWith('csrftoken='))
?.split('=')[1];
if (!token && config.method !== 'get') {
console.warn('CSRF token not found for non-GET request');
}
if (token) {
config.headers['x-csrftoken'] = token;
}

View File

@ -36,7 +36,7 @@ export function ExportDropdown<T extends object = object>({
const { elementRef: ref, isOpen, toggle, handleBlur, hide } = useDropdown();
function handleExport(format: 'csv' | 'json') {
if (!data || data.length === 0) {
if (!data?.length) {
toast.error(infoMsg.noDataToExport);
return;
}

View File

@ -82,6 +82,7 @@ export function useDataTable<TData extends RowData>({
pageSize: paginationPerPage
});
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
state: {
pagination: pagination,

View File

@ -70,7 +70,7 @@ export function DiagramFlow({
return (
<div
tabIndex={-1}
className={cn('relative cc-mask-sides max-w-480 w-[100dvw]', spaceMode && 'space-mode', className)}
className={cn('relative cc-mask-sides max-w-480 w-dvw', spaceMode && 'space-mode', className)}
style={{ ...style, height: height }}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}

View File

@ -1,4 +1,5 @@
import clsx from 'clsx';
import DOMPurify from 'dompurify';
import { ZodError } from 'zod';
import { type AxiosError, isAxiosError } from '@/backend/api-transport';
@ -18,11 +19,17 @@ export function DescribeError({ error }: { error: ErrorData }) {
} else if (typeof error === 'string') {
return <p>{error}</p>;
} else if (error instanceof ZodError) {
let errorData: unknown;
try {
/* eslint-disable-next-line @typescript-eslint/no-base-to-string */
errorData = JSON.parse(error.toString());
} catch {
errorData = { message: error.message, issues: error.issues };
}
return (
<div>
<p>Ошибка валидации данных</p>
{/* eslint-disable-next-line @typescript-eslint/no-base-to-string */}
<PrettyJson data={JSON.parse(error.toString()) as unknown} />;
<PrettyJson data={errorData} />
</div>
);
} else if (!isAxiosError(error)) {
@ -34,7 +41,7 @@ export function DescribeError({ error }: { error: ErrorData }) {
<p>
<b>Message:</b> {error.message}
</p>
{error.stack && <pre className='whitespace-pre-wrap p-2 overflow-x-auto break-words'>{error.stack}</pre>}
{error.stack && <pre className='whitespace-pre-wrap p-2 overflow-x-auto wrap-break-word'>{error.stack}</pre>}
</div>
);
}
@ -60,6 +67,14 @@ export function DescribeError({ error }: { error: ErrorData }) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const isHtml = isResponseHtml(error.response);
let sanitizedHtml: string | null = null;
if (isHtml) {
sanitizedHtml = DOMPurify.sanitize(error.response.data as string, {
USE_PROFILES: { html: true },
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'ul', 'li', 'br'],
ALLOWED_ATTR: []
});
}
return (
<div>
<p className='underline'>Ошибка</p>
@ -67,8 +82,11 @@ export function DescribeError({ error }: { error: ErrorData }) {
{error.response.data && (
<>
<p className='mt-2 underline'>Описание</p>
{isHtml ? <div dangerouslySetInnerHTML={{ __html: error.response.data as TrustedHTML }} /> : null}
{!isHtml ? <PrettyJson data={error.response.data as object} /> : null}
{isHtml && sanitizedHtml ? (
<div dangerouslySetInnerHTML={{ __html: sanitizedHtml }} />
) : (
<PrettyJson data={error.response.data as object} />
)}
</>
)}
</div>

View File

@ -1,7 +1,5 @@
'use client';
import assert from 'assert';
import { useRef, useState } from 'react';
import { ChevronDownIcon } from 'lucide-react';
@ -71,9 +69,10 @@ export function ComboMulti<Option>({
} else {
if ('onAdd' in restProps && typeof restProps.onAdd === 'function') {
restProps.onAdd(newValue);
} else {
assert('onChange' in restProps);
} else if ('onChange' in restProps && typeof restProps.onChange === 'function') {
restProps.onChange([...value, newValue]);
} else {
throw new Error('onChange is not defined');
}
setOpen(false);
}
@ -82,9 +81,10 @@ export function ComboMulti<Option>({
function handleRemoveValue(delValue: Option) {
if ('onRemove' in restProps && typeof restProps.onRemove === 'function') {
restProps.onRemove(delValue);
} else {
assert('onChange' in restProps);
} else if ('onChange' in restProps && typeof restProps.onChange === 'function') {
restProps.onChange(value.filter(v => v !== delValue));
} else {
throw new Error('onChange is not defined');
}
setOpen(false);
}
@ -93,9 +93,10 @@ export function ComboMulti<Option>({
event.stopPropagation();
if ('onClear' in restProps && typeof restProps.onClear === 'function') {
restProps.onClear();
} else {
assert('onChange' in restProps);
} else if ('onChange' in restProps && typeof restProps.onChange === 'function') {
restProps.onChange([]);
} else {
throw new Error('onChange is not defined');
}
setOpen(false);
}

View File

@ -1,14 +1,17 @@
'use client';
import clsx from 'clsx';
import { type HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components/badge-help';
import { useEscapeKey } from '@/hooks/use-escape-key';
import { useDialogsStore } from '@/stores/dialogs';
import { globalIDs } from '@/utils/constants';
import { prepareTooltip } from '@/utils/utils';
import { Button, MiniButton, SubmitButton } from '../control';
import { IconClose } from '../icons';
import { IconAlert, IconClose } from '../icons';
import { type Styling } from '../props';
import { cn } from '../utils';
@ -33,7 +36,7 @@ interface ModalFormProps extends ModalProps {
submitText?: string;
/** Tooltip for the submit button when the form is invalid. */
submitInvalidTooltip?: string;
validationHint?: string;
/** Indicates that submit button is enabled. */
canSubmit?: boolean;
@ -60,7 +63,7 @@ export function ModalForm({
canSubmit = true,
submitText = 'Продолжить',
submitInvalidTooltip,
validationHint,
beforeSubmit,
onSubmit,
onCancel,
@ -121,7 +124,7 @@ export function ModalForm({
<div
className={cn(
'@container/modal',
'max-h-[calc(100svh-8rem)] max-w-[100svw] xs:max-w-[calc(100svw-2rem)]',
'max-h-[calc(100svh-8rem)] max-w-svw xs:max-w-[calc(100svw-2rem)]',
'overscroll-contain outline-hidden',
overflowVisible ? 'overflow-visible' : 'overflow-auto',
className
@ -131,14 +134,24 @@ export function ModalForm({
{children}
</div>
<div className='z-pop my-2 flex gap-12 justify-center text-sm'>
<SubmitButton
autoFocus
text={submitText}
title={!canSubmit ? submitInvalidTooltip : ''}
className='min-w-28'
disabled={!canSubmit}
/>
<div className={clsx('z-pop relative', 'my-2', 'flex justify-center', 'text-sm', !validationHint && 'gap-12')}>
<SubmitButton autoFocus text={submitText} className='min-w-28' disabled={!canSubmit} />
{validationHint ? (
<div
className={clsx(
'pt-0.5 w-12',
'text-muted-foreground cc-animate-color duration-fade',
canSubmit ? 'hover:text-constructive' : 'hover:text-destructive'
)}
>
<IconAlert
size='1.5rem'
className='mx-auto'
data-tooltip-id={globalIDs.tooltip}
data-tooltip-html={validationHint}
/>
</div>
) : null}
<Button text='Отмена' aria-label='Закрыть' className='min-w-28' onClick={handleCancel} />
</div>
</form>

View File

@ -73,7 +73,7 @@ export function ModalView({
<div
className={cn(
'@container/modal',
'max-w-[100svw] xs:max-w-[calc(100svw-2rem)]',
'max-w-svw xs:max-w-[calc(100svw-2rem)]',
'overscroll-contain outline-hidden',
overflowVisible ? 'overflow-visible' : 'overflow-auto',
fullScreen ? 'max-h-[calc(100svh-2rem)]' : 'max-h-[calc(100svh-8rem)]',

View File

@ -7,7 +7,7 @@ const invalidVarMark = Decoration.mark({
});
const validMark = Decoration.mark({
class: 'text-(--acc-fg-purple)'
class: 'text-accent-purple-foreground'
});
class MarkVariablesPlugin {

View File

@ -31,7 +31,7 @@ export function TabPromptEdit({ label, description, text, setText }: TabPromptEd
label='Текст шаблона'
value={text}
onChange={setText}
maxHeight='10rem'
maxHeight='9.5rem'
availableVariables={availableVariables}
/>
</div>

View File

@ -53,7 +53,7 @@ export function DlgCreatePromptTemplate() {
submitText='Создать'
canSubmit={canSubmit}
onSubmit={event => void handleSubmit(onSubmit)(event)}
submitInvalidTooltip='Введите уникальное название шаблона'
validationHint={canSubmit ? '' : 'Введите уникальное название шаблона'}
className='cc-column w-140 max-h-120 py-2 px-6'
helpTopic={HelpTopic.ASSISTANT}
>

View File

@ -93,7 +93,7 @@ export function LoginPage() {
// ====== Internals =========
function ServerError({ error }: { error: ErrorData }): React.ReactElement | null {
if (isAxiosError(error) && error.response && error.response.status === 400) {
if (isAxiosError(error) && error.response?.status === 400) {
return (
<div className='text-sm select-text text-destructive'>
На Портале отсутствует такое сочетание имени пользователя и пароля

View File

@ -97,7 +97,7 @@ export function Component() {
// ====== Internals =========
function ServerError({ error }: { error: ErrorData }): React.ReactElement {
if (isAxiosError(error) && error.response && error.response.status === 404) {
if (isAxiosError(error) && error.response?.status === 404) {
return <div className='mx-auto mt-6 text-sm select-text text-destructive'>Данная ссылка не действительна</div>;
}
return <InfoError error={error} />;

View File

@ -53,7 +53,7 @@ export function Component() {
// ====== Internals =========
function ServerError({ error }: { error: ErrorData }): React.ReactElement {
if (isAxiosError(error) && error.response && error.response.status === 400) {
if (isAxiosError(error) && error.response?.status === 400) {
return (
<div className='mx-auto mt-6 text-sm select-text text-destructive'>Данный email не используется на Портале.</div>
);

View File

@ -13,23 +13,23 @@ export function HelpFormulaTree() {
<h2>Виды узлов</h2>
<p className='m-0'>
<span className='cc-sample-color bg-(--acc-bg-green)' />
<span className='cc-sample-color bg-accent-green' />
объявление идентификатора
</p>
<p className='m-0'>
<span className='cc-sample-color bg-(--acc-bg-teal)' />
<span className='cc-sample-color bg-accent-teal' />
глобальный идентификатор
</p>
<p className='m-0'>
<span className='cc-sample-color bg-(--acc-bg-orange)' />
<span className='cc-sample-color bg-accent-orange' />
логическое выражение
</p>
<p className='m-0'>
<span className='cc-sample-color bg-(--acc-bg-blue)' />
<span className='cc-sample-color bg-accent-blue' />
типизированное выражение
</p>
<p className='m-0'>
<span className='cc-sample-color bg-(--acc-bg-red)' />
<span className='cc-sample-color bg-accent-red' />
присвоение и итерация
</p>
<p className='m-0'>

View File

@ -34,7 +34,7 @@ export function HelpLibrary() {
<ul>
<li>
<span className='text-(--acc-fg-green)'>зеленым текстом</span> выделены ОСС
<span className='text-accent-green-foreground'>зеленым текстом</span> выделены ОСС
</li>
<li>
<kbd>клик</kbd> по строке - переход к редактированию схемы

View File

@ -1,5 +1,7 @@
'use client';
import { useEffect } from 'react';
import { urls, useConceptNavigation } from '@/app';
import { useAuthSuspense } from '@/features/auth';
@ -9,12 +11,14 @@ export function HomePage() {
const router = useConceptNavigation();
const { isAnonymous } = useAuthSuspense();
if (isAnonymous) {
useEffect(() => {
// Note: Timeout is needed to let router initialize
setTimeout(() => router.replace({ path: urls.login }), PARAMETER.minimalTimeout);
} else {
setTimeout(() => router.replace({ path: urls.library }), PARAMETER.minimalTimeout);
}
const timeoutId = setTimeout(() => {
router.replace({ path: isAnonymous ? urls.login : urls.library });
}, PARAMETER.minimalTimeout);
return () => clearTimeout(timeoutId);
}, [router, isAnonymous]);
return null;
}

View File

@ -47,7 +47,11 @@ export function DlgChangeLocation() {
overflowVisible
header='Изменение расположения'
submitText='Переместить'
submitInvalidTooltip={`Допустимы буквы, цифры, подчерк, пробел и "!". Сегмент пути не может начинаться и заканчиваться пробелом. Общая длина (с корнем) не должна превышать ${limits.len_location}`}
validationHint={
isValid
? ''
: `Допустимы буквы, цифры, подчерк, пробел и "!". Сегмент пути не может начинаться и заканчиваться пробелом. Общая длина (с корнем) не должна превышать ${limits.len_location}`
}
canSubmit={isValid && isDirty}
onSubmit={event => void handleSubmit(onSubmit)(event)}
className='w-130 pb-3 px-6 h-36'

View File

@ -6,7 +6,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { Checkbox, TextArea, TextInput } from '@/components/input';
import { ModalForm } from '@/components/modal';
import { useDialogsStore } from '@/stores/dialogs';
import { errorMsg } from '@/utils/labels';
import { hintMsg } from '@/utils/labels';
import { type ICreateVersionDTO, type IVersionInfo, schemaCreateVersion } from '../backend/types';
import { useCreateVersion } from '../backend/use-create-version';
@ -41,7 +41,15 @@ export function DlgCreateVersion() {
mode: 'onChange'
});
const version = useWatch({ control, name: 'version' });
const canSubmit = !!version && !versions.find(ver => ver.version === version);
const { canSubmit, hint } = (() => {
if (!version) {
return { canSubmit: false, hint: hintMsg.versionEmpty };
} else if (versions.find(ver => ver.version === version)) {
return { canSubmit: false, hint: hintMsg.versionTaken };
} else {
return { canSubmit: true, hint: '' };
}
})();
function onSubmit(data: ICreateVersionDTO) {
return versionCreate({ itemID, data }).then(onCreate);
@ -52,7 +60,7 @@ export function DlgCreateVersion() {
header='Создание версии'
className='cc-column w-120 py-2 px-6'
canSubmit={canSubmit}
submitInvalidTooltip={errorMsg.versionTaken}
validationHint={hint}
submitText='Создать'
onSubmit={event => void handleSubmit(onSubmit)(event)}
>

View File

@ -12,7 +12,7 @@ import { IconReset, IconSave } from '@/components/icons';
import { TextArea, TextInput } from '@/components/input';
import { ModalView } from '@/components/modal';
import { useDialogsStore } from '@/stores/dialogs';
import { errorMsg } from '@/utils/labels';
import { hintMsg } from '@/utils/labels';
import { type IUpdateVersionDTO, schemaUpdateVersion } from '../../backend/types';
import { useDeleteVersion } from '../../backend/use-delete-version';
@ -106,7 +106,7 @@ export function DlgEditVersions() {
<div className='cc-icons h-fit'>
<MiniButton
type='submit'
title={isValid ? 'Сохранить изменения' : errorMsg.versionTaken}
title={isValid ? 'Сохранить изменения' : hintMsg.versionTaken}
aria-label='Сохранить изменения'
icon={<IconSave size='1.25rem' className='icon-primary' />}
disabled={!isDirty || !isValid || isProcessing}

View File

@ -215,9 +215,9 @@ export const ossApi = {
}
}),
relocateConstituents: (data: IRelocateConstituentsDTO) =>
relocateConstituents: ({ itemID, data }: { itemID: number; data: IRelocateConstituentsDTO }) =>
axiosPost<IRelocateConstituentsDTO>({
endpoint: `/api/oss/relocate-constituents`,
endpoint: `/api/oss/${itemID}/relocate-constituents`,
request: {
data: data,
successMessage: infoMsg.changesSaved

View File

@ -19,6 +19,6 @@ export const useRelocateConstituents = () => {
onError: () => client.invalidateQueries()
});
return {
relocateConstituents: (data: IRelocateConstituentsDTO) => mutation.mutateAsync(data)
relocateConstituents: (data: { itemID: number; data: IRelocateConstituentsDTO }) => mutation.mutateAsync(data)
};
};

View File

@ -18,7 +18,7 @@ export function OperationTooltip() {
clickable
id={globalIDs.operation_tooltip}
layer='z-topmost'
className='max-w-140 dense max-h-120! overflow-y-auto!'
className='max-w-100 lg:max-w-140 dense max-h-80 lg:max-h-120! overflow-y-auto!'
hidden={!hoverItem}
>
{hoverItem && isOperationNode ? <InfoOperation operation={hoverItem} /> : null}

View File

@ -9,6 +9,7 @@ import { HelpTopic } from '@/features/help';
import { ModalForm } from '@/components/modal';
import { TabLabel, TabList, TabPanel, Tabs } from '@/components/tabs';
import { useDialogsStore } from '@/stores/dialogs';
import { hintMsg } from '@/utils/labels';
import { type ICreateBlockDTO, type IOssLayout, schemaCreateBlock } from '../../backend/types';
import { useCreateBlock } from '../../backend/use-create-block';
@ -77,7 +78,17 @@ export function DlgCreateBlock() {
const children_blocks = useWatch({ control: methods.control, name: 'children_blocks' });
const children_operations = useWatch({ control: methods.control, name: 'children_operations' });
const [activeTab, setActiveTab] = useState<TabID>(TabID.CARD);
const canSubmit = methods.formState.isValid && !!title && !manager.oss.blocks.some(block => block.title === title);
const { canSubmit, hint } = (() => {
if (!title) {
return { canSubmit: false, hint: hintMsg.titleEmpty };
} else if (manager.oss.blocks.some(block => block.title === title)) {
return { canSubmit: false, hint: hintMsg.blockTitleTaken };
} else if (!methods.formState.isValid) {
return { canSubmit: false, hint: hintMsg.formInvalid };
} else {
return { canSubmit: true, hint: '' };
}
})();
function onSubmit(data: ICreateBlockDTO) {
data.position = manager.newBlockPosition(data);
@ -90,6 +101,7 @@ export function DlgCreateBlock() {
header='Создание блока'
submitText='Создать'
canSubmit={canSubmit}
validationHint={hint}
onSubmit={event => void methods.handleSubmit(onSubmit)(event)}
className='w-160 px-6 h-110'
helpTopic={HelpTopic.CC_STRUCTURING}

View File

@ -28,6 +28,7 @@ export function TabBlockCard({ oss }: TabBlockCardProps) {
<TextInput
id='operation_title' //
label='Название'
placeholder='Введите название'
{...register('item_data.title')}
error={errors.item_data?.title}
/>

View File

@ -8,6 +8,7 @@ import { HelpTopic } from '@/features/help';
import { TextArea, TextInput } from '@/components/input';
import { ModalForm } from '@/components/modal';
import { useDialogsStore } from '@/stores/dialogs';
import { hintMsg } from '@/utils/labels';
import { type ICreateSchemaDTO, type IOssLayout, schemaCreateSchema } from '../backend/types';
import { useCreateSchema } from '../backend/use-create-schema';
@ -64,7 +65,17 @@ export function DlgCreateSchema() {
mode: 'onChange'
});
const alias = useWatch({ control: control, name: 'item_data.alias' });
const canSubmit = isValid && !!alias && !manager.oss.operations.some(operation => operation.alias === alias);
const { canSubmit, hint } = (() => {
if (!alias) {
return { canSubmit: false, hint: hintMsg.aliasEmpty };
} else if (manager.oss.operations.some(operation => operation.alias === alias)) {
return { canSubmit: false, hint: hintMsg.schemaAliasTaken };
} else if (!isValid) {
return { canSubmit: false, hint: hintMsg.formInvalid };
} else {
return { canSubmit: true, hint: '' };
}
})();
function onSubmit(data: ICreateSchemaDTO) {
data.position = manager.newOperationPosition(data);
@ -77,6 +88,7 @@ export function DlgCreateSchema() {
header='Создание операции: Новая схема'
submitText='Создать'
canSubmit={canSubmit}
validationHint={hint}
onSubmit={event => void handleSubmit(onSubmit)(event)}
className='w-180 px-6 pb-3 cc-column'
helpTopic={HelpTopic.CC_OSS}
@ -84,6 +96,7 @@ export function DlgCreateSchema() {
<TextInput
id='operation_title' //
label='Название'
placeholder='Введите название'
{...register('item_data.title')}
error={errors.item_data?.title}
/>
@ -93,6 +106,7 @@ export function DlgCreateSchema() {
id='operation_alias' //
label='Сокращение'
className='w-80'
placeholder='Введите сокращение'
{...register('item_data.alias')}
error={errors.item_data?.alias}
/>

View File

@ -10,6 +10,7 @@ import { Loader } from '@/components/loader';
import { ModalForm } from '@/components/modal';
import { TabLabel, TabList, TabPanel, Tabs } from '@/components/tabs';
import { useDialogsStore } from '@/stores/dialogs';
import { hintMsg } from '@/utils/labels';
import { type ICreateSynthesisDTO, type IOssLayout, schemaCreateSynthesis } from '../../backend/types';
import { useCreateSynthesis } from '../../backend/use-create-synthesis';
@ -73,8 +74,17 @@ export function DlgCreateSynthesis() {
});
const alias = useWatch({ control: methods.control, name: 'item_data.alias' });
const [activeTab, setActiveTab] = useState<TabID>(TabID.ARGUMENTS);
const canSubmit =
methods.formState.isValid && !!alias && !manager.oss.operations.some(operation => operation.alias === alias);
const { canSubmit, hint } = (() => {
if (!alias) {
return { canSubmit: false, hint: hintMsg.aliasEmpty };
} else if (manager.oss.operations.some(operation => operation.alias === alias)) {
return { canSubmit: false, hint: hintMsg.schemaAliasTaken };
} else if (!methods.formState.isValid) {
return { canSubmit: false, hint: hintMsg.formInvalid };
} else {
return { canSubmit: true, hint: '' };
}
})();
function onSubmit(data: ICreateSynthesisDTO) {
data.position = manager.newOperationPosition(data);
@ -87,6 +97,7 @@ export function DlgCreateSynthesis() {
header='Создание операции синтеза'
submitText='Создать'
canSubmit={canSubmit}
validationHint={hint}
onSubmit={event => void methods.handleSubmit(onSubmit)(event)}
className='w-180 px-6 h-128'
helpTopic={HelpTopic.CC_OSS}

View File

@ -32,6 +32,7 @@ export function TabArguments({ oss }: TabArgumentsProps) {
<TextInput
id='operation_title'
label='Название'
placeholder='Введите название'
{...register('item_data.title')}
error={errors.item_data?.title}
/>
@ -40,6 +41,7 @@ export function TabArguments({ oss }: TabArgumentsProps) {
<TextInput
id='operation_alias' //
label='Сокращение'
placeholder='Введите сокращение'
className='w-80'
{...register('item_data.alias')}
error={errors.item_data?.alias}

View File

@ -45,7 +45,12 @@ export function TabSubstitutions({ oss }: TabSubstitutionsProps) {
)}
/>
<TextArea disabled value={validator.msg} rows={4} className={isCorrect ? '' : 'border-(--acc-fg-red) border-2'} />
<TextArea
disabled
value={validator.msg}
rows={4}
className={isCorrect ? '' : 'border-accent-red-foreground border-2'}
/>
</div>
);
}

View File

@ -67,6 +67,7 @@ export function DlgEditBlock() {
<TextInput
id='operation_title' //
label='Название'
placeholder='Введите название'
{...register('item_data.title')}
error={errors.item_data?.title}
/>

View File

@ -10,6 +10,7 @@ import { Loader } from '@/components/loader';
import { ModalForm } from '@/components/modal';
import { TabLabel, TabList, TabPanel, Tabs } from '@/components/tabs';
import { useDialogsStore } from '@/stores/dialogs';
import { hintMsg } from '@/utils/labels';
import { type IOssLayout, type IUpdateOperationDTO, OperationType, schemaUpdateOperation } from '../../backend/types';
import { useOssSuspense } from '../../backend/use-oss';
@ -79,6 +80,7 @@ export function DlgEditOperation() {
header='Редактирование операции'
submitText='Сохранить'
canSubmit={canSubmit}
validationHint={canSubmit ? '' : hintMsg.formInvalid}
onSubmit={event => void methods.handleSubmit(onSubmit)(event)}
className='w-160 px-6 h-128'
helpTopic={HelpTopic.UI_SUBSTITUTIONS}

View File

@ -22,6 +22,7 @@ export function TabOperation({ oss }: TabOperationProps) {
<TextInput
id='operation_title'
label='Название'
placeholder='Введите название'
{...register('item_data.title')}
error={errors.item_data?.title}
/>
@ -29,6 +30,7 @@ export function TabOperation({ oss }: TabOperationProps) {
<TextInput
id='operation_alias' //
label='Сокращение'
placeholder='Введите сокращение'
className='w-80'
{...register('item_data.alias')}
error={errors.item_data?.alias}

View File

@ -45,7 +45,12 @@ export function TabSubstitutions({ oss }: TabSubstitutionsProps) {
)}
/>
<TextArea disabled value={validator.msg} rows={4} className={isCorrect ? '' : 'border-(--acc-fg-red) border-2'} />
<TextArea
disabled
value={validator.msg}
rows={4}
className={isCorrect ? '' : 'border-accent-red-foreground border-2'}
/>
</div>
);
}

View File

@ -11,6 +11,7 @@ import { PickSchema } from '@/features/library/components/pick-schema';
import { Checkbox, TextArea, TextInput } from '@/components/input';
import { ModalForm } from '@/components/modal';
import { useDialogsStore } from '@/stores/dialogs';
import { hintMsg } from '@/utils/labels';
import { type IImportSchemaDTO, type IOssLayout, schemaImportSchema } from '../backend/types';
import { useImportSchema } from '../backend/use-import-schema';
@ -73,7 +74,17 @@ export function DlgImportSchema() {
});
const alias = useWatch({ control: control, name: 'item_data.alias' });
const clone_source = useWatch({ control: control, name: 'clone_source' });
const canSubmit = isValid && !!alias && !manager.oss.operations.some(operation => operation.alias === alias);
const { canSubmit, hint } = (() => {
if (!alias) {
return { canSubmit: false, hint: hintMsg.aliasEmpty };
} else if (manager.oss.operations.some(operation => operation.alias === alias)) {
return { canSubmit: false, hint: hintMsg.schemaAliasTaken };
} else if (!isValid) {
return { canSubmit: false, hint: hintMsg.formInvalid };
} else {
return { canSubmit: true, hint: '' };
}
})();
function onSubmit(data: IImportSchemaDTO) {
data.position = manager.newOperationPosition(data);
@ -101,6 +112,7 @@ export function DlgImportSchema() {
header='Создание операции: Загрузка'
submitText='Создать'
canSubmit={canSubmit}
validationHint={hint}
onSubmit={event => void handleSubmit(onSubmit)(event)}
className='w-180 px-6 pb-3 cc-column'
helpTopic={HelpTopic.CC_OSS}

View File

@ -15,6 +15,7 @@ import { MiniButton } from '@/components/control';
import { Loader } from '@/components/loader';
import { ModalForm } from '@/components/modal';
import { useDialogsStore } from '@/stores/dialogs';
import { hintMsg } from '@/utils/labels';
import { type IOssLayout, type IRelocateConstituentsDTO, schemaRelocateConstituents } from '../backend/types';
import { useOssSuspense } from '../backend/use-oss';
@ -105,13 +106,13 @@ export function DlgRelocateConstituents() {
function onSubmit(data: IRelocateConstituentsDTO) {
data.items = moveTarget;
if (!layout || JSON.stringify(layout) === JSON.stringify(oss.layout)) {
return relocateConstituents(data);
return relocateConstituents({ itemID: oss.id, data: data });
} else {
return updatePositions({
isSilent: true,
itemID: oss.id,
data: layout
}).then(() => relocateConstituents(data));
}).then(() => relocateConstituents({ itemID: oss.id, data: data }));
}
}
@ -120,7 +121,7 @@ export function DlgRelocateConstituents() {
header='Перенос конституент'
submitText='Переместить'
canSubmit={canSubmit}
submitInvalidTooltip='Необходимо выбрать хотя бы одну собственную конституенту'
validationHint={canSubmit ? '' : hintMsg.relocateEmpty}
onSubmit={event => void handleSubmit(onSubmit)(event)}
className='w-160 h-132 py-3 px-6'
helpTopic={HelpTopic.UI_RELOCATE_CST}

View File

@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { type Edge, MarkerType, type Node, useEdgesState, useNodesState } from 'reactflow';
import { type IConstituenta, type IRSForm } from '@/features/rsform';
import { FocusLabel } from '@/features/rsform/components/term-graph/focus-label';
import { TGEdgeTypes } from '@/features/rsform/components/term-graph/graph/tg-edge-types';
import { TGNodeTypes } from '@/features/rsform/components/term-graph/graph/tg-node-types';
import { SelectColoring } from '@/features/rsform/components/term-graph/select-coloring';
@ -100,11 +101,10 @@ export function TGReadonlyFlow({ schema }: TGReadonlyFlowProps) {
return (
<div className='relative w-full h-full flex flex-col'>
<div className='cc-tab-tools flex flex-col mt-2 items-center rounded-b-2xl backdrop-blur-xs'>
<div className='cc-tab-tools mt-2 flex flex-col items-center rounded-b-2xl backdrop-blur-xs'>
<ToolbarGraphFilter />
{focusCst ? (
<ToolbarFocusedCst className='-translate-x-9' focus={focusCst} resetFocus={() => setFocusCst(null)} />
) : null}
{focusCst ? <ToolbarFocusedCst resetFocus={() => setFocusCst(null)} /> : null}
{focusCst ? <FocusLabel label={focusCst.alias} /> : null}
</div>
<div className='absolute z-pop top-24 sm:top-16 left-2 sm:left-3 w-54 flex flex-col pointer-events-none'>
<SelectColoring className='rounded-b-none' schema={schema} />

View File

@ -48,10 +48,10 @@ export const rsformsApi = {
endpoint: version ? `/api/versions/${version}/export-file` : `/api/rsforms/${itemID}/export-trs`,
options: { responseType: 'blob' }
}),
upload: (data: IRSFormUploadDTO) =>
upload: ({ itemID, data }: { itemID: number; data: IRSFormUploadDTO }) =>
axiosPatch<IRSFormUploadDTO, IRSFormDTO>({
schema: schemaRSForm,
endpoint: `/api/rsforms/${data.itemID}/load-trs`,
endpoint: `/api/rsforms/${itemID}/load-trs`,
request: {
data: data,
successMessage: infoMsg.uploadSuccess

View File

@ -110,11 +110,11 @@ export class RSFormLoader {
parent.spawn_alias.push(cst.alias);
}
});
this.schema.attribution.forEach(assoc => {
const container = this.cstByID.get(assoc.container)!;
container.attributes.push(assoc.attribute);
this.full_graph.addEdge(container.id, assoc.attribute);
this.association_graph.addEdge(container.id, assoc.attribute);
this.schema.attribution.forEach(attrib => {
const container = this.cstByID.get(attrib.container)!;
container.attributes.push(attrib.attribute);
this.full_graph.addEdge(container.id, attrib.attribute);
this.association_graph.addEdge(container.id, attrib.attribute);
});
}

View File

@ -51,10 +51,8 @@ export type IRSFormDTO = z.infer<typeof schemaRSForm>;
/** Represents data, used for uploading {@link IRSForm} as file. */
export interface IRSFormUploadDTO {
itemID: number;
load_metadata: boolean;
file: File;
fileName: string;
}
/** Represents {@link IConstituenta} data, used in creation process. */
@ -97,7 +95,7 @@ export type ISubstitutionsDTO = z.infer<typeof schemaSubstitutions>;
/** Represents data for creating or deleting an Attribution. */
export type IAttribution = z.infer<typeof schemaAttribution>;
/** Represents data for clearing all associations for a target constituenta. */
/** Represents data for clearing all attributions for a target constituenta. */
export type IAttributionTargetDTO = z.infer<typeof schemaAttributionTarget>;
/** Represents Constituenta list. */

View File

@ -29,6 +29,6 @@ export const useUploadTRS = () => {
onError: () => client.invalidateQueries()
});
return {
upload: (data: IRSFormUploadDTO) => mutation.mutateAsync(data)
upload: (data: { itemID: number; data: IRSFormUploadDTO }) => mutation.mutateAsync(data)
};
};

View File

@ -11,7 +11,7 @@ interface InfoConstituentaProps extends React.ComponentProps<'div'> {
export function InfoConstituenta({ data, className, ...restProps }: InfoConstituentaProps) {
return (
<div className={cn('dense min-w-60 break-words', className)} {...restProps}>
<div className={cn('dense min-w-60 wrap-break-word', className)} {...restProps}>
<h2 className='cursor-default' title={data.is_inherited ? ' наследник' : undefined}>
{data.alias}
{data.is_inherited ? <IconChild size='1rem' className='inline-icon align-middle ml-1 mt-1' /> : null}

View File

@ -58,18 +58,26 @@ export function PickSubstitutions({
const [rightArgument, setRightArgument] = useState<ILibraryItem | null>(
schemas.length === 1 && allowSelfSubstitution ? schemas[0] : null
);
const leftItems = !leftArgument
? []
: (leftArgument as IRSForm).items.filter(
cst => !value.find(item => item.original === cst.id) && (!filterCst || filterCst(cst))
);
const [leftCst, setLeftCst] = useState<IConstituenta | null>(null);
const [rightCst, setRightCst] = useState<IConstituenta | null>(null);
const leftItems = !leftArgument
? []
: (leftArgument as IRSForm).items.filter(
cst =>
cst.id !== rightCst?.id && //
!value.find(item => item.original === cst.id) &&
(!filterCst || filterCst(cst))
);
const rightItems = !rightArgument
? []
: (rightArgument as IRSForm).items.filter(
cst => !value.find(item => item.original === cst.id) && (!filterCst || filterCst(cst))
cst =>
cst.id !== leftCst?.id && //
!value.find(item => item.original === cst.id) &&
(!filterCst || filterCst(cst))
);
const [deleteRight, setDeleteRight] = useState(true);
@ -78,7 +86,12 @@ export function PickSubstitutions({
const [ignores, setIgnores] = useState<ISubstituteConstituents[]>([]);
const filteredSuggestions =
suggestions?.filter(
item => !ignores.find(ignore => ignore.original === item.original && ignore.substitution === item.substitution)
item =>
!ignores.find(
ignore =>
(ignore.original === item.original && ignore.substitution === item.substitution) ||
(ignore.original === item.substitution && ignore.substitution === item.original)
)
) ?? [];
const substitutionData: IMultiSubstitution[] = [
@ -193,7 +206,7 @@ export function PickSubstitutions({
size: 0,
cell: props =>
props.row.original.is_suggestion ? (
<div className='max-w-fit'>
<div className='flex max-w-fit'>
<MiniButton
title='Принять предложение'
icon={<IconAccept size='1rem' className='icon-green' />}

View File

@ -23,7 +23,7 @@ import { extractGlobals } from '../../models/rslang-api';
import { ccBracketMatching } from './bracket-matching';
import { rsNavigation } from './click-navigation';
import { RSLanguage } from './rslang';
import { getSymbolSubstitute, RSTextWrapper } from './text-editing';
import { getLigatureSymbol, getSymbolSubstitute, isPotentialLigature, RSTextWrapper } from './text-editing';
import { rsHoverTooltip } from './tooltip';
const editorSetup: BasicSetupOptions = {
@ -130,56 +130,77 @@ export const RSInput = forwardRef<ReactCodeMirrorRef, RSInputProps>(
...(noTooltip || !schema ? [] : [rsHoverTooltip(schema, onOpenEdit !== undefined)])
];
function handleAutoComplete(text: RSTextWrapper): boolean {
const selection = text.getSelection();
if (!selection.empty || !schema) {
return false;
}
const wordRange = text.getWord(selection.from);
if (!wordRange) {
return false;
}
const word = text.getText(wordRange.from, wordRange.to);
if (word.length > 2 && (word.startsWith('Pr') || word.startsWith('pr'))) {
text.setSelection(wordRange.from, wordRange.from + 2);
if (word.startsWith('Pr')) {
text.replaceWith('pr');
} else {
text.replaceWith('Pr');
}
return true;
}
const hint = text.getText(selection.from - 1, selection.from);
const type = guessCstType(hint);
if (hint === getCstTypePrefix(type)) {
text.setSelection(selection.from - 1, selection.from);
}
const takenAliases = [...extractGlobals(thisRef.current?.view?.state.doc.toString() ?? '')];
const newAlias = generateAlias(type, schema, takenAliases);
text.replaceWith(newAlias);
return true;
}
function processInput(event: React.KeyboardEvent<HTMLDivElement>): boolean {
const text = new RSTextWrapper(thisRef.current as Required<ReactCodeMirrorRef>);
if ((event.ctrlKey || event.metaKey) && event.code === 'Space') {
return handleAutoComplete(text);
}
if (event.altKey) {
return text.processAltKey(event.code, event.shiftKey);
}
if (!(event.ctrlKey || event.metaKey)) {
if (isPotentialLigature(event.key)) {
const selection = text.getSelection();
const prevSymbol = text.getText(selection.from - 1, selection.from);
const newSymbol = getLigatureSymbol(prevSymbol, event.key);
if (newSymbol) {
text.setSelection(selection.from - 1, selection.to);
text.replaceWith(newSymbol);
}
return !!newSymbol;
} else {
const newSymbol = getSymbolSubstitute(event.code, event.shiftKey);
if (newSymbol) {
text.replaceWith(newSymbol);
}
return !!newSymbol;
}
}
if (event.code === 'KeyQ' && onAnalyze) {
onAnalyze();
return true;
}
return false;
}
function handleInput(event: React.KeyboardEvent<HTMLDivElement>) {
if (!thisRef.current) {
return;
}
const text = new RSTextWrapper(thisRef.current as Required<ReactCodeMirrorRef>);
if ((event.ctrlKey || event.metaKey) && event.code === 'Space') {
const selection = text.getSelection();
if (!selection.empty || !schema) {
return;
}
const wordRange = text.getWord(selection.from);
if (wordRange) {
const word = text.getText(wordRange.from, wordRange.to);
if (word.length > 2 && (word.startsWith('Pr') || word.startsWith('pr'))) {
text.setSelection(wordRange.from, wordRange.from + 2);
if (word.startsWith('Pr')) {
text.replaceWith('pr');
} else {
text.replaceWith('Pr');
}
event.preventDefault();
event.stopPropagation();
return;
}
}
const hint = text.getText(selection.from - 1, selection.from);
const type = guessCstType(hint);
if (hint === getCstTypePrefix(type)) {
text.setSelection(selection.from - 1, selection.from);
}
const takenAliases = [...extractGlobals(thisRef.current.view?.state.doc.toString() ?? '')];
const newAlias = generateAlias(type, schema, takenAliases);
text.replaceWith(newAlias);
event.preventDefault();
event.stopPropagation();
} else if (event.altKey) {
if (text.processAltKey(event.code, event.shiftKey)) {
event.preventDefault();
event.stopPropagation();
}
} else if (!(event.ctrlKey || event.metaKey)) {
const newSymbol = getSymbolSubstitute(event.code, event.shiftKey);
if (newSymbol) {
text.replaceWith(newSymbol);
event.preventDefault();
event.stopPropagation();
}
} else if (event.code === 'KeyQ' && onAnalyze) {
onAnalyze();
if (processInput(event)) {
event.preventDefault();
event.stopPropagation();
}

View File

@ -6,6 +6,24 @@ import { CodeMirrorWrapper } from '@/utils/codemirror';
import { TokenID } from '../../backend/types';
/* Determines whether an input key is a potential ligature. */
export function isPotentialLigature(text: string): boolean {
return text == '=';
}
/* Determines symbol for ligature replacement. */
export function getLigatureSymbol(prevSymbol: string, text: string): string | undefined {
if (text == '=') {
// prettier-ignore
switch (prevSymbol) {
case '!': return '≠';
case '>': return '≥';
case '<': return '≤';
}
}
return undefined;
}
export function getSymbolSubstitute(keyCode: string, shiftPressed: boolean): string | undefined {
// prettier-ignore
if (shiftPressed) {
@ -172,7 +190,12 @@ export class RSTextWrapper extends CodeMirrorWrapper {
this.replaceWith('∃');
return true;
case TokenID.SET_IN:
this.replaceWith('∈');
if (this.getPrevSymbol() == '!') {
this.setSelection(selection.from - 1, selection.to);
this.replaceWith('∉');
} else {
this.replaceWith('∈');
}
return true;
case TokenID.SET_NOT_IN:
this.replaceWith('∉');
@ -184,7 +207,12 @@ export class RSTextWrapper extends CodeMirrorWrapper {
this.replaceWith('&');
return true;
case TokenID.SUBSET_OR_EQ:
this.replaceWith('⊆');
if (this.getPrevSymbol() == '!') {
this.setSelection(selection.from - 1, selection.to);
this.replaceWith('⊄');
} else {
this.replaceWith('⊆');
}
return true;
case TokenID.LOGIC_IMPLICATION:
this.replaceWith('⇒');
@ -208,13 +236,23 @@ export class RSTextWrapper extends CodeMirrorWrapper {
this.replaceWith('Z');
return true;
case TokenID.SUBSET:
this.replaceWith('⊂');
if (this.getPrevSymbol() == '!') {
this.setSelection(selection.from - 1, selection.to);
this.replaceWith('⊄');
} else {
this.replaceWith('⊂');
}
return true;
case TokenID.NOT_SUBSET:
this.replaceWith('⊄');
return true;
case TokenID.EQUAL:
this.replaceWith('=');
if (this.getPrevSymbol() == '!') {
this.setSelection(selection.from - 1, selection.to);
this.replaceWith('≠');
} else {
this.replaceWith('=');
}
return true;
case TokenID.NOTEQUAL:
this.replaceWith('≠');

View File

@ -0,0 +1,16 @@
import clsx from 'clsx';
interface FocusLabelProps {
label: string;
}
export function FocusLabel({ label }: FocusLabelProps) {
return (
<div className={clsx('px-1', 'select-none', 'hover:bg-background', 'text-accent-purple-foreground rounded-md')}>
<span aria-label='Фокус-конституента' className='whitespace-nowrap'>
Фокус
<b> {label} </b>
</span>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More