Compare commits

...

10 Commits

Author SHA1 Message Date
Ivan
513d6a5b71 R: Optimize database queries
Some checks failed
Backend CI / build (3.12) (push) Has been cancelled
Frontend CI / build (22.x) (push) Has been cancelled
2024-08-06 23:13:57 +03:00
Ivan
91642816b1 F: Implement editors change for OSS -> RSForm 2024-08-06 22:35:32 +03:00
Ivan
edc728fe00 R: Upgrade to eslint9 2024-08-06 14:38:10 +03:00
Ivan
be0dfdefd8 npm update 2024-08-06 00:05:01 +03:00
Ivan
92a0453b18 F: Implement RSForm and OSS attribute sync 2024-08-05 23:53:07 +03:00
Ivan
c0d01957ff M: Update icons 2024-08-03 11:59:51 +03:00
Ivan
cc8cb2d53c M: Update manuals 2024-08-03 11:30:47 +03:00
Ivan
95d38cea7c R: Test Inheritance and update initial data 2024-08-02 15:15:53 +03:00
Ivan
e96206b7db M: Improve node positioning in OSS 2024-08-02 11:17:27 +03:00
Ivan
044a484607 M: Add Operation to Inheritance model 2024-08-02 11:16:41 +03:00
83 changed files with 11746 additions and 6311 deletions

View File

@ -28,7 +28,9 @@
"editor.tabSize": 4, "editor.tabSize": 4,
"editor.insertSpaces": true, "editor.insertSpaces": true,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.organizeImports": "explicit" "source.organizeImports": "explicit",
"source.fixAll.ts": "never",
"source.fixAll.eslint": "never"
} }
}, },
"python.testing.unittestArgs": ["-v", "-s", "./tests", "-p", "test*.py"], "python.testing.unittestArgs": ["-v", "-s", "./tests", "-p", "test*.py"],

View File

@ -0,0 +1,21 @@
# Generated by Django 5.0.7 on 2024-08-06 19:31
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='editor',
name='editor',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 5.0.7 on 2024-08-06 19:35
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0002_alter_editor_editor'),
]
operations = [
migrations.AlterField(
model_name='librarytemplate',
name='lib_source',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='library.libraryitem', verbose_name='Источник'),
),
]

View File

@ -1,14 +1,11 @@
''' Models: Editor. ''' ''' Models: Editor. '''
from typing import TYPE_CHECKING from typing import Iterable
from django.db import transaction from django.db import transaction
from django.db.models import CASCADE, DateTimeField, ForeignKey, Model from django.db.models import CASCADE, DateTimeField, ForeignKey, Model
from apps.users.models import User from apps.users.models import User
if TYPE_CHECKING:
from .LibraryItem import LibraryItem
class Editor(Model): class Editor(Model):
''' Editor list. ''' ''' Editor list. '''
@ -20,8 +17,7 @@ class Editor(Model):
editor: ForeignKey = ForeignKey( editor: ForeignKey = ForeignKey(
verbose_name='Редактор', verbose_name='Редактор',
to=User, to=User,
on_delete=CASCADE, on_delete=CASCADE
null=True
) )
time_create: DateTimeField = DateTimeField( time_create: DateTimeField = DateTimeField(
verbose_name='Дата добавления', verbose_name='Дата добавления',
@ -38,17 +34,17 @@ class Editor(Model):
return f'{self.item}: {self.editor}' return f'{self.item}: {self.editor}'
@staticmethod @staticmethod
def add(item: 'LibraryItem', user: User) -> bool: def add(item: int, user: int) -> bool:
''' Add Editor for item. ''' ''' Add Editor for item. '''
if Editor.objects.filter(item=item, editor=user).exists(): if Editor.objects.filter(item_id=item, editor_id=user).exists():
return False return False
Editor.objects.create(item=item, editor=user) Editor.objects.create(item_id=item, editor_id=user)
return True return True
@staticmethod @staticmethod
def remove(item: 'LibraryItem', user: User) -> bool: def remove(item: int, user: int) -> bool:
''' Remove Editor. ''' ''' Remove Editor. '''
editor = Editor.objects.filter(item=item, editor=user) editor = Editor.objects.filter(item_id=item, editor_id=user).only('pk')
if not editor.exists(): if not editor.exists():
return False return False
editor.delete() editor.delete()
@ -56,16 +52,40 @@ class Editor(Model):
@staticmethod @staticmethod
@transaction.atomic @transaction.atomic
def set(item: 'LibraryItem', users: list[User]): def set(item: int, users: Iterable[int]):
''' Set editors for item. ''' ''' Set editors for item. '''
processed: list[User] = [] processed: set[int] = set()
for editor_item in Editor.objects.filter(item=item): for editor_item in Editor.objects.filter(item_id=item).only('editor_id'):
if editor_item.editor not in users: editor_id = editor_item.editor_id
if editor_id not in users:
editor_item.delete() editor_item.delete()
else: else:
processed.append(editor_item.editor) processed.add(editor_id)
for user in users:
if user not in processed:
processed.add(user)
Editor.objects.create(item_id=item, editor_id=user)
@staticmethod
@transaction.atomic
def set_and_return_diff(item: int, users: Iterable[int]) -> tuple[list[int], list[int]]:
''' Set editors for item and return diff. '''
processed: list[int] = []
deleted: list[int] = []
added: list[int] = []
for editor_item in Editor.objects.filter(item_id=item).only('editor_id'):
editor_id = editor_item.editor_id
if editor_id not in users:
deleted.append(editor_id)
editor_item.delete()
else:
processed.append(editor_id)
for user in users: for user in users:
if user not in processed: if user not in processed:
processed.append(user) processed.append(user)
Editor.objects.create(item=item, editor=user) added.append(user)
Editor.objects.create(item_id=item, editor_id=user)
return (added, deleted)

View File

@ -16,7 +16,6 @@ from django.db.models import (
from apps.users.models import User from apps.users.models import User
from .Editor import Editor
from .Subscription import Subscription from .Subscription import Subscription
from .Version import Version from .Version import Version
@ -115,18 +114,19 @@ class LibraryItem(Model):
def get_absolute_url(self): def get_absolute_url(self):
return f'/api/library/{self.pk}' return f'/api/library/{self.pk}'
def subscribers(self) -> list[User]: def subscribers(self) -> QuerySet[User]:
''' Get all subscribers for this item. ''' ''' Get all subscribers for this item. '''
return [subscription.user for subscription in Subscription.objects.filter(item=self.pk).only('user')] return User.objects.filter(subscription__item=self.pk)
def editors(self) -> list[User]: def editors(self) -> QuerySet[User]:
''' Get all Editors of this item. ''' ''' Get all Editors of this item. '''
return [item.editor for item in Editor.objects.filter(item=self.pk).only('editor')] return User.objects.filter(editor__item=self.pk)
def versions(self) -> QuerySet[Version]: def versions(self) -> QuerySet[Version]:
''' Get all Versions of this item. ''' ''' Get all Versions of this item. '''
return Version.objects.filter(item=self.pk).order_by('-time_create') return Version.objects.filter(item=self.pk).order_by('-time_create')
# TODO: move to View layer
@transaction.atomic @transaction.atomic
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
''' Save updating subscriptions and connected operations. ''' ''' Save updating subscriptions and connected operations. '''
@ -135,7 +135,7 @@ class LibraryItem(Model):
subscribe = self._state.adding and self.owner subscribe = self._state.adding and self.owner
super().save(*args, **kwargs) super().save(*args, **kwargs)
if subscribe: if subscribe:
Subscription.subscribe(user=self.owner, item=self) Subscription.subscribe(user=self.owner_id, item=self.pk)
def _update_connected_operations(self): def _update_connected_operations(self):
# using method level import to prevent circular dependency # using method level import to prevent circular dependency

View File

@ -7,8 +7,7 @@ class LibraryTemplate(Model):
lib_source: ForeignKey = ForeignKey( lib_source: ForeignKey = ForeignKey(
verbose_name='Источник', verbose_name='Источник',
to='library.LibraryItem', to='library.LibraryItem',
on_delete=CASCADE, on_delete=CASCADE
null=True
) )
class Meta: class Meta:

View File

@ -1,13 +1,8 @@
''' Models: Subscription. ''' ''' Models: Subscription. '''
from typing import TYPE_CHECKING
from django.db.models import CASCADE, ForeignKey, Model from django.db.models import CASCADE, ForeignKey, Model
from apps.users.models import User from apps.users.models import User
if TYPE_CHECKING:
from .LibraryItem import LibraryItem
class Subscription(Model): class Subscription(Model):
''' User subscription to library item. ''' ''' User subscription to library item. '''
@ -32,17 +27,17 @@ class Subscription(Model):
return f'{self.user} -> {self.item}' return f'{self.user} -> {self.item}'
@staticmethod @staticmethod
def subscribe(user: User, item: 'LibraryItem') -> bool: def subscribe(user: int, item: int) -> bool:
''' Add subscription. ''' ''' Add subscription. '''
if Subscription.objects.filter(user=user, item=item).exists(): if Subscription.objects.filter(user_id=user, item_id=item).exists():
return False return False
Subscription.objects.create(user=user, item=item) Subscription.objects.create(user_id=user, item_id=item)
return True return True
@staticmethod @staticmethod
def unsubscribe(user: User, item: 'LibraryItem') -> bool: def unsubscribe(user: int, item: int) -> bool:
''' Remove subscription. ''' ''' Remove subscription. '''
sub = Subscription.objects.filter(user=user, item=item) sub = Subscription.objects.filter(user_id=user, item_id=item).only('pk')
if not sub.exists(): if not sub.exists():
return False return False
sub.delete() sub.delete()

View File

@ -83,10 +83,10 @@ class LibraryItemDetailsSerializer(serializers.ModelSerializer):
read_only_fields = ('owner', 'id', 'item_type') read_only_fields = ('owner', 'id', 'item_type')
def get_subscribers(self, instance: LibraryItem) -> list[int]: def get_subscribers(self, instance: LibraryItem) -> list[int]:
return [item.pk for item in instance.subscribers()] return list(instance.subscribers().values_list('pk', flat=True))
def get_editors(self, instance: LibraryItem) -> list[int]: def get_editors(self, instance: LibraryItem) -> list[int]:
return [item.pk for item in instance.editors()] return list(instance.editors().values_list('pk', flat=True))
def get_versions(self, instance: LibraryItem) -> list: def get_versions(self, instance: LibraryItem) -> list:
return [VersionInnerSerializer(item).data for item in instance.versions()] return [VersionInnerSerializer(item).data for item in instance.versions()]
@ -94,9 +94,9 @@ class LibraryItemDetailsSerializer(serializers.ModelSerializer):
class UserTargetSerializer(serializers.Serializer): class UserTargetSerializer(serializers.Serializer):
''' Serializer: Target single User. ''' ''' Serializer: Target single User. '''
user = PKField(many=False, queryset=User.objects.all()) user = PKField(many=False, queryset=User.objects.all().only('pk'))
class UsersListSerializer(serializers.Serializer): class UsersListSerializer(serializers.Serializer):
''' Serializer: List of Users. ''' ''' Serializer: List of Users. '''
users = PKField(many=True, queryset=User.objects.all()) users = PKField(many=True, queryset=User.objects.all().only('pk'))

View File

@ -34,44 +34,65 @@ class TestEditor(TestCase):
def test_add_editor(self): def test_add_editor(self):
self.assertTrue(Editor.add(self.item, self.user1)) self.assertTrue(Editor.add(self.item.pk, self.user1.pk))
self.assertEqual(len(self.item.editors()), 1) self.assertEqual(self.item.editors().count(), 1)
self.assertTrue(self.user1 in self.item.editors()) self.assertTrue(self.user1 in list(self.item.editors()))
self.assertFalse(Editor.add(self.item, self.user1)) self.assertFalse(Editor.add(self.item.pk, self.user1.pk))
self.assertEqual(len(self.item.editors()), 1) self.assertEqual(self.item.editors().count(), 1)
self.assertTrue(Editor.add(self.item, self.user2)) self.assertTrue(Editor.add(self.item.pk, self.user2.pk))
self.assertEqual(len(self.item.editors()), 2) self.assertEqual(self.item.editors().count(), 2)
self.assertTrue(self.user1 in self.item.editors()) self.assertTrue(self.user1 in self.item.editors())
self.assertTrue(self.user2 in self.item.editors()) self.assertTrue(self.user2 in self.item.editors())
self.user1.delete() self.user1.delete()
self.assertEqual(len(self.item.editors()), 1) self.assertEqual(self.item.editors().count(), 1)
def test_remove_editor(self): def test_remove_editor(self):
self.assertFalse(Editor.remove(self.item, self.user1)) self.assertFalse(Editor.remove(self.item.pk, self.user1.pk))
Editor.add(self.item, self.user1) Editor.add(self.item.pk, self.user1.pk)
Editor.add(self.item, self.user2) Editor.add(self.item.pk, self.user2.pk)
self.assertEqual(len(self.item.editors()), 2) self.assertEqual(self.item.editors().count(), 2)
self.assertTrue(Editor.remove(self.item, self.user1)) self.assertTrue(Editor.remove(self.item.pk, self.user1.pk))
self.assertEqual(len(self.item.editors()), 1) self.assertEqual(self.item.editors().count(), 1)
self.assertTrue(self.user2 in self.item.editors()) self.assertTrue(self.user2 in self.item.editors())
self.assertFalse(Editor.remove(self.item, self.user1)) self.assertFalse(Editor.remove(self.item.pk, self.user1.pk))
def test_set_editors(self): def test_set_editors(self):
Editor.set(self.item, [self.user1]) Editor.set(self.item.pk, [self.user1.pk])
self.assertEqual(self.item.editors(), [self.user1]) self.assertEqual(list(self.item.editors()), [self.user1])
Editor.set(self.item, [self.user1, self.user1]) Editor.set(self.item.pk, [self.user1.pk, self.user1.pk])
self.assertEqual(self.item.editors(), [self.user1]) self.assertEqual(list(self.item.editors()), [self.user1])
Editor.set(self.item, []) Editor.set(self.item.pk, [])
self.assertEqual(self.item.editors(), []) self.assertEqual(list(self.item.editors()), [])
Editor.set(self.item, [self.user1, self.user2]) Editor.set(self.item.pk, [self.user1.pk, self.user2.pk])
self.assertEqual(set(self.item.editors()), set([self.user1, self.user2]))
def test_set_editors_return_diff(self):
added, deleted = Editor.set_and_return_diff(self.item.pk, [self.user1.pk])
self.assertEqual(added, [self.user1.pk])
self.assertEqual(deleted, [])
self.assertEqual(list(self.item.editors()), [self.user1])
added, deleted = Editor.set_and_return_diff(self.item.pk, [self.user1.pk, self.user1.pk])
self.assertEqual(added, [])
self.assertEqual(deleted, [])
self.assertEqual(list(self.item.editors()), [self.user1])
added, deleted = Editor.set_and_return_diff(self.item.pk, [])
self.assertEqual(added, [])
self.assertEqual(deleted, [self.user1.pk])
self.assertEqual(list(self.item.editors()), [])
added, deleted = Editor.set_and_return_diff(self.item.pk, [self.user1.pk, self.user2.pk])
self.assertEqual(added, [self.user1.pk, self.user2.pk])
self.assertEqual(deleted, [])
self.assertEqual(set(self.item.editors()), set([self.user1, self.user2])) self.assertEqual(set(self.item.editors()), set([self.user1, self.user2]))

View File

@ -37,33 +37,33 @@ class TestSubscription(TestCase):
def test_subscribe(self): def test_subscribe(self):
item = LibraryItem.objects.create(item_type=LibraryItemType.RSFORM, title='Test') item = LibraryItem.objects.create(item_type=LibraryItemType.RSFORM, title='Test')
self.assertEqual(len(item.subscribers()), 0) self.assertEqual(item.subscribers().count(), 0)
self.assertTrue(Subscription.subscribe(self.user1, item)) self.assertTrue(Subscription.subscribe(self.user1.pk, item.pk))
self.assertEqual(len(item.subscribers()), 1) self.assertEqual(item.subscribers().count(), 1)
self.assertTrue(self.user1 in item.subscribers()) self.assertTrue(self.user1 in item.subscribers())
self.assertFalse(Subscription.subscribe(self.user1, item)) self.assertFalse(Subscription.subscribe(self.user1.pk, item.pk))
self.assertEqual(len(item.subscribers()), 1) self.assertEqual(item.subscribers().count(), 1)
self.assertTrue(Subscription.subscribe(self.user2, item)) self.assertTrue(Subscription.subscribe(self.user2.pk, item.pk))
self.assertEqual(len(item.subscribers()), 2) self.assertEqual(item.subscribers().count(), 2)
self.assertTrue(self.user1 in item.subscribers()) self.assertTrue(self.user1 in item.subscribers())
self.assertTrue(self.user2 in item.subscribers()) self.assertTrue(self.user2 in item.subscribers())
self.user1.delete() self.user1.delete()
self.assertEqual(len(item.subscribers()), 1) self.assertEqual(item.subscribers().count(), 1)
def test_unsubscribe(self): def test_unsubscribe(self):
item = LibraryItem.objects.create(item_type=LibraryItemType.RSFORM, title='Test') item = LibraryItem.objects.create(item_type=LibraryItemType.RSFORM, title='Test')
self.assertFalse(Subscription.unsubscribe(self.user1, item)) self.assertFalse(Subscription.unsubscribe(self.user1.pk, item.pk))
Subscription.subscribe(self.user1, item) Subscription.subscribe(self.user1.pk, item.pk)
Subscription.subscribe(self.user2, item) Subscription.subscribe(self.user2.pk, item.pk)
self.assertEqual(len(item.subscribers()), 2) self.assertEqual(item.subscribers().count(), 2)
self.assertTrue(Subscription.unsubscribe(self.user1, item)) self.assertTrue(Subscription.unsubscribe(self.user1.pk, item.pk))
self.assertEqual(len(item.subscribers()), 1) self.assertEqual(item.subscribers().count(), 1)
self.assertTrue(self.user2 in item.subscribers()) self.assertTrue(self.user2 in item.subscribers())
self.assertFalse(Subscription.unsubscribe(self.user1, item)) self.assertFalse(Subscription.unsubscribe(self.user1.pk, item.pk))

View File

@ -183,57 +183,6 @@ class TestLibraryViewset(EndpointTester):
self.unowned.refresh_from_db() self.unowned.refresh_from_db()
self.assertEqual(self.unowned.location, data['location']) self.assertEqual(self.unowned.location, data['location'])
@decl_endpoint('/api/library/{item}/add-editor', method='patch')
def test_add_editor(self):
time_update = self.owned.time_update
data = {'user': self.invalid_user}
self.executeBadData(data=data, item=self.owned.pk)
data = {'user': self.user.pk}
self.executeNotFound(data=data, item=self.invalid_item)
self.executeForbidden(data=data, item=self.unowned.pk)
self.executeOK(data=data, item=self.owned.pk)
self.owned.refresh_from_db()
self.assertEqual(self.owned.time_update, time_update)
self.assertEqual(self.owned.editors(), [self.user])
self.executeOK(data=data)
self.assertEqual(self.owned.editors(), [self.user])
data = {'user': self.user2.pk}
self.executeOK(data=data)
self.assertEqual(set(self.owned.editors()), set([self.user, self.user2]))
@decl_endpoint('/api/library/{item}/remove-editor', method='patch')
def test_remove_editor(self):
time_update = self.owned.time_update
data = {'user': self.invalid_user}
self.executeBadData(data=data, item=self.owned.pk)
data = {'user': self.user.pk}
self.executeNotFound(data=data, item=self.invalid_item)
self.executeForbidden(data=data, item=self.unowned.pk)
self.executeOK(data=data, item=self.owned.pk)
self.owned.refresh_from_db()
self.assertEqual(self.owned.time_update, time_update)
self.assertEqual(self.owned.editors(), [])
Editor.add(item=self.owned, user=self.user)
self.executeOK(data=data)
self.assertEqual(self.owned.editors(), [])
Editor.add(item=self.owned, user=self.user)
Editor.add(item=self.owned, user=self.user2)
data = {'user': self.user2.pk}
self.executeOK(data=data)
self.assertEqual(self.owned.editors(), [self.user])
@decl_endpoint('/api/library/{item}/set-editors', method='patch') @decl_endpoint('/api/library/{item}/set-editors', method='patch')
def test_set_editors(self): def test_set_editors(self):
time_update = self.owned.time_update time_update = self.owned.time_update
@ -248,18 +197,18 @@ class TestLibraryViewset(EndpointTester):
self.executeOK(data=data, item=self.owned.pk) self.executeOK(data=data, item=self.owned.pk)
self.owned.refresh_from_db() self.owned.refresh_from_db()
self.assertEqual(self.owned.time_update, time_update) self.assertEqual(self.owned.time_update, time_update)
self.assertEqual(self.owned.editors(), [self.user]) self.assertEqual(list(self.owned.editors()), [self.user])
self.executeOK(data=data) self.executeOK(data=data)
self.assertEqual(self.owned.editors(), [self.user]) self.assertEqual(list(self.owned.editors()), [self.user])
data = {'users': [self.user2.pk]} data = {'users': [self.user2.pk]}
self.executeOK(data=data) self.executeOK(data=data)
self.assertEqual(self.owned.editors(), [self.user2]) self.assertEqual(list(self.owned.editors()), [self.user2])
data = {'users': []} data = {'users': []}
self.executeOK(data=data) self.executeOK(data=data)
self.assertEqual(self.owned.editors(), []) self.assertEqual(list(self.owned.editors()), [])
data = {'users': [self.user2.pk, self.user.pk]} data = {'users': [self.user2.pk, self.user.pk]}
self.executeOK(data=data) self.executeOK(data=data)
@ -320,9 +269,9 @@ class TestLibraryViewset(EndpointTester):
response = self.executeOK() response = self.executeOK()
self.assertFalse(response_contains(response, self.unowned)) self.assertFalse(response_contains(response, self.unowned))
Subscription.subscribe(user=self.user, item=self.unowned) Subscription.subscribe(user=self.user.pk, item=self.unowned.pk)
Subscription.subscribe(user=self.user2, item=self.unowned) Subscription.subscribe(user=self.user2.pk, item=self.unowned.pk)
Subscription.subscribe(user=self.user2, item=self.owned) Subscription.subscribe(user=self.user2.pk, item=self.owned.pk)
response = self.executeOK() response = self.executeOK()
self.assertTrue(response_contains(response, self.unowned)) self.assertTrue(response_contains(response, self.unowned))

View File

@ -4,15 +4,15 @@ from typing import cast
from django.db import transaction from django.db import transaction
from django.db.models import Q from django.db.models import Q
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema, extend_schema_view from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework import filters, generics from rest_framework import generics
from rest_framework import status as c from rest_framework import status as c
from rest_framework import viewsets from rest_framework import viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from apps.oss.models import OperationSchema
from apps.rsform.models import RSForm from apps.rsform.models import RSForm
from apps.rsform.serializers import RSFormParseSerializer from apps.rsform.serializers import RSFormParseSerializer
from apps.users.models import User from apps.users.models import User
@ -27,10 +27,8 @@ from .. import serializers as s
class LibraryViewSet(viewsets.ModelViewSet): class LibraryViewSet(viewsets.ModelViewSet):
''' Endpoint: Library operations. ''' ''' Endpoint: Library operations. '''
queryset = m.LibraryItem.objects.all() queryset = m.LibraryItem.objects.all()
# TODO: consider using .only() for performance
filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
filterset_fields = ['item_type', 'owner']
ordering_fields = ('item_type', 'owner', 'alias', 'title', 'time_update')
ordering = '-time_update' ordering = '-time_update'
def get_serializer_class(self): def get_serializer_class(self):
@ -52,8 +50,6 @@ class LibraryViewSet(viewsets.ModelViewSet):
'set_owner', 'set_owner',
'set_access_policy', 'set_access_policy',
'set_location', 'set_location',
'add_editor',
'remove_editor',
'set_editors' 'set_editors'
]: ]:
access_level = permissions.ItemOwner access_level = permissions.ItemOwner
@ -129,7 +125,7 @@ class LibraryViewSet(viewsets.ModelViewSet):
def subscribe(self, request: Request, pk): def subscribe(self, request: Request, pk):
''' Endpoint: Subscribe current user to item. ''' ''' Endpoint: Subscribe current user to item. '''
item = self._get_item() item = self._get_item()
m.Subscription.subscribe(user=cast(User, self.request.user), item=item) m.Subscription.subscribe(user=cast(int, self.request.user.pk), item=item.pk)
return Response(status=c.HTTP_200_OK) return Response(status=c.HTTP_200_OK)
@extend_schema( @extend_schema(
@ -146,7 +142,7 @@ class LibraryViewSet(viewsets.ModelViewSet):
def unsubscribe(self, request: Request, pk): def unsubscribe(self, request: Request, pk):
''' Endpoint: Unsubscribe current user from item. ''' ''' Endpoint: Unsubscribe current user from item. '''
item = self._get_item() item = self._get_item()
m.Subscription.unsubscribe(user=cast(User, self.request.user), item=item) m.Subscription.unsubscribe(user=cast(int, self.request.user.pk), item=item.pk)
return Response(status=c.HTTP_200_OK) return Response(status=c.HTTP_200_OK)
@extend_schema( @extend_schema(
@ -165,29 +161,19 @@ class LibraryViewSet(viewsets.ModelViewSet):
item = self._get_item() item = self._get_item()
serializer = s.UserTargetSerializer(data=request.data) serializer = s.UserTargetSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
new_owner = serializer.validated_data['user'] new_owner = serializer.validated_data['user'].pk
m.LibraryItem.objects.filter(pk=item.pk).update(owner=new_owner) if new_owner == item.owner_id:
return Response(status=c.HTTP_200_OK) return Response(status=c.HTTP_200_OK)
with transaction.atomic():
if item.item_type == m.LibraryItemType.OPERATION_SCHEMA:
owned_schemas = OperationSchema(item).owned_schemas().only('owner')
for schema in owned_schemas:
schema.owner_id = new_owner
m.LibraryItem.objects.bulk_update(owned_schemas, ['owner'])
item.owner_id = new_owner
item.save(update_fields=['owner'])
@extend_schema(
summary='set AccessPolicy for item',
tags=['Library'],
request=s.AccessPolicySerializer,
responses={
c.HTTP_200_OK: None,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='set-access-policy')
def set_access_policy(self, request: Request, pk):
''' Endpoint: Set item AccessPolicy. '''
item = self._get_item()
serializer = s.AccessPolicySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
new_policy = serializer.validated_data['access_policy']
m.LibraryItem.objects.filter(pk=item.pk).update(access_policy=new_policy)
return Response(status=c.HTTP_200_OK) return Response(status=c.HTTP_200_OK)
@extend_schema( @extend_schema(
@ -208,49 +194,52 @@ class LibraryViewSet(viewsets.ModelViewSet):
serializer = s.LocationSerializer(data=request.data) serializer = s.LocationSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
location: str = serializer.validated_data['location'] location: str = serializer.validated_data['location']
if location == item.location:
return Response(status=c.HTTP_200_OK)
if location.startswith(m.LocationHead.LIBRARY) and not self.request.user.is_staff: if location.startswith(m.LocationHead.LIBRARY) and not self.request.user.is_staff:
return Response(status=c.HTTP_403_FORBIDDEN) return Response(status=c.HTTP_403_FORBIDDEN)
m.LibraryItem.objects.filter(pk=item.pk).update(location=location)
with transaction.atomic():
if item.item_type == m.LibraryItemType.OPERATION_SCHEMA:
owned_schemas = OperationSchema(item).owned_schemas().only('location')
for schema in owned_schemas:
schema.location = location
m.LibraryItem.objects.bulk_update(owned_schemas, ['location'])
item.location = location
item.save(update_fields=['location'])
return Response(status=c.HTTP_200_OK) return Response(status=c.HTTP_200_OK)
@extend_schema( @extend_schema(
summary='add editor for item', summary='set AccessPolicy for item',
tags=['Library'], tags=['Library'],
request=s.UserTargetSerializer, request=s.AccessPolicySerializer,
responses={ responses={
c.HTTP_200_OK: None, c.HTTP_200_OK: None,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None, c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None c.HTTP_404_NOT_FOUND: None
} }
) )
@action(detail=True, methods=['patch'], url_path='add-editor') @action(detail=True, methods=['patch'], url_path='set-access-policy')
def add_editor(self, request: Request, pk): def set_access_policy(self, request: Request, pk):
''' Endpoint: Add editor for item. ''' ''' Endpoint: Set item AccessPolicy. '''
item = self._get_item() item = self._get_item()
serializer = s.UserTargetSerializer(data=request.data) serializer = s.AccessPolicySerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
new_editor = serializer.validated_data['user'] new_policy = serializer.validated_data['access_policy']
m.Editor.add(item=item, user=new_editor) if new_policy == item.access_policy:
return Response(status=c.HTTP_200_OK) return Response(status=c.HTTP_200_OK)
with transaction.atomic():
if item.item_type == m.LibraryItemType.OPERATION_SCHEMA:
owned_schemas = OperationSchema(item).owned_schemas().only('access_policy')
for schema in owned_schemas:
schema.access_policy = new_policy
m.LibraryItem.objects.bulk_update(owned_schemas, ['access_policy'])
item.access_policy = new_policy
item.save(update_fields=['access_policy'])
@extend_schema(
summary='remove editor for item',
tags=['Library'],
request=s.UserTargetSerializer,
responses={
c.HTTP_200_OK: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='remove-editor')
def remove_editor(self, request: Request, pk):
''' Endpoint: Remove editor for item. '''
item = self._get_item()
serializer = s.UserTargetSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
editor = serializer.validated_data['user']
m.Editor.remove(item=item, user=editor)
return Response(status=c.HTTP_200_OK) return Response(status=c.HTTP_200_OK)
@extend_schema( @extend_schema(
@ -269,8 +258,32 @@ class LibraryViewSet(viewsets.ModelViewSet):
item = self._get_item() item = self._get_item()
serializer = s.UsersListSerializer(data=request.data) serializer = s.UsersListSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
editors = serializer.validated_data['users'] editors: list[int] = request.data['users']
m.Editor.set(item=item, users=editors)
with transaction.atomic():
added, deleted = m.Editor.set_and_return_diff(item.pk, editors)
if len(added) >= 0 or len(deleted) >= 0:
owned_schemas = OperationSchema(item).owned_schemas().only('pk')
if owned_schemas.exists():
m.Editor.objects.filter(
item__in=owned_schemas,
editor_id__in=deleted
).delete()
existing_editors = m.Editor.objects.filter(
item__in=owned_schemas,
editor__in=added
).values_list('item_id', 'editor_id')
existing_editor_set = set(existing_editors)
new_editors = [
m.Editor(item=schema, editor_id=user)
for schema in owned_schemas
for user in added
if (item.id, user) not in existing_editor_set
]
m.Editor.objects.bulk_create(new_editors)
return Response(status=c.HTTP_200_OK) return Response(status=c.HTTP_200_OK)

View File

@ -25,6 +25,14 @@ class SynthesisSubstitutionAdmin(admin.ModelAdmin):
search_fields = ['id', 'operation', 'original', 'substitution'] search_fields = ['id', 'operation', 'original', 'substitution']
class InheritanceAdmin(admin.ModelAdmin):
''' Admin model: Inheritance. '''
ordering = ['operation']
list_display = ['id', 'operation', 'parent', 'child']
search_fields = ['id', 'operation', 'parent', 'child']
admin.site.register(models.Operation, OperationAdmin) admin.site.register(models.Operation, OperationAdmin)
admin.site.register(models.Argument, ArgumentAdmin) admin.site.register(models.Argument, ArgumentAdmin)
admin.site.register(models.Substitution, SynthesisSubstitutionAdmin) admin.site.register(models.Substitution, SynthesisSubstitutionAdmin)
admin.site.register(models.Inheritance, InheritanceAdmin)

View File

@ -0,0 +1,20 @@
# Generated by Django 5.0.7 on 2024-08-02 07:01
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('oss', '0004_remove_substitution_transfer_term'),
]
operations = [
migrations.AddField(
model_name='inheritance',
name='operation',
field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, related_name='inheritances', to='oss.operation', verbose_name='Операция'),
preserve_default=False,
),
]

View File

@ -4,6 +4,12 @@ from django.db.models import CASCADE, ForeignKey, Model
class Inheritance(Model): class Inheritance(Model):
''' Inheritance links parent and child constituents in synthesis operation.''' ''' Inheritance links parent and child constituents in synthesis operation.'''
operation: ForeignKey = ForeignKey(
verbose_name='Операция',
to='oss.Operation',
on_delete=CASCADE,
related_name='inheritances'
)
parent: ForeignKey = ForeignKey( parent: ForeignKey = ForeignKey(
verbose_name='Исходная конституента', verbose_name='Исходная конституента',
to='rsform.Constituenta', to='rsform.Constituenta',

View File

@ -51,6 +51,14 @@ class OperationSchema:
''' Operation substitutions. ''' ''' Operation substitutions. '''
return Substitution.objects.filter(operation__oss=self.model) return Substitution.objects.filter(operation__oss=self.model)
def owned_schemas(self) -> QuerySet[LibraryItem]:
''' Get QuerySet containing all result schemas owned by current OSS. '''
return LibraryItem.objects.filter(
producer__oss=self.model,
owner_id=self.model.owner_id,
location=self.model.location
)
def update_positions(self, data: list[dict]): def update_positions(self, data: list[dict]):
''' Update positions. ''' ''' Update positions. '''
lookup = {x['id']: x for x in data} lookup = {x['id']: x for x in data}
@ -161,7 +169,7 @@ class OperationSchema:
access_policy=self.model.access_policy, access_policy=self.model.access_policy,
location=self.model.location location=self.model.location
) )
Editor.set(schema.model, self.model.editors()) Editor.set(schema.model.pk, self.model.editors().values_list('pk', flat=True))
operation.result = schema.model operation.result = schema.model
operation.save() operation.save()
self.save() self.save()
@ -197,6 +205,7 @@ class OperationSchema:
parent = parents.get(cst.pk) parent = parents.get(cst.pk)
assert parent is not None assert parent is not None
Inheritance.objects.create( Inheritance.objects.create(
operation=operation,
child=cst, child=cst,
parent=parent parent=parent
) )

View File

@ -48,7 +48,7 @@ class OperationCreateSerializer(serializers.Serializer):
create_schema = serializers.BooleanField(default=False, required=False) create_schema = serializers.BooleanField(default=False, required=False)
item_data = OperationCreateData() item_data = OperationCreateData()
arguments = PKField(many=True, queryset=Operation.objects.all(), required=False) arguments = PKField(many=True, queryset=Operation.objects.all().only('pk'), required=False)
positions = serializers.ListField( positions = serializers.ListField(
child=OperationPositionSerializer(), child=OperationPositionSerializer(),
@ -67,7 +67,7 @@ class OperationUpdateSerializer(serializers.Serializer):
target = PKField(many=False, queryset=Operation.objects.all()) target = PKField(many=False, queryset=Operation.objects.all())
item_data = OperationUpdateData() item_data = OperationUpdateData()
arguments = PKField(many=True, queryset=Operation.objects.all(), required=False) arguments = PKField(many=True, queryset=Operation.objects.all().only('oss_id', 'result_id'), required=False)
substitutions = serializers.ListField( substitutions = serializers.ListField(
child=SubstitutionSerializerBase(), child=SubstitutionSerializerBase(),
required=False required=False
@ -121,8 +121,8 @@ class OperationUpdateSerializer(serializers.Serializer):
class OperationTargetSerializer(serializers.Serializer): class OperationTargetSerializer(serializers.Serializer):
''' Serializer: Delete operation. ''' ''' Serializer: Target single operation. '''
target = PKField(many=False, queryset=Operation.objects.all()) target = PKField(many=False, queryset=Operation.objects.all().only('oss_id', 'result_id'))
positions = serializers.ListField( positions = serializers.ListField(
child=OperationPositionSerializer(), child=OperationPositionSerializer(),
default=[] default=[]

View File

@ -1,4 +1,5 @@
''' Tests for Django Models. ''' ''' Tests for Django Models. '''
from .t_Argument import * from .t_Argument import *
from .t_Inheritance import *
from .t_Operation import * from .t_Operation import *
from .t_Substitution import * from .t_Substitution import *

View File

@ -0,0 +1,51 @@
''' Testing models: Inheritance. '''
from django.test import TestCase
from apps.oss.models import Inheritance, Operation, OperationSchema, OperationType
from apps.rsform.models import Constituenta, RSForm
class TestInheritance(TestCase):
''' Testing Inheritance model. '''
def setUp(self):
self.oss = OperationSchema.create(alias='T1')
self.ks1 = RSForm.create(title='Test1', alias='KS1')
self.ks2 = RSForm.create(title='Test2', alias='KS2')
self.operation = Operation.objects.create(
oss=self.oss.model,
alias='KS1',
operation_type=OperationType.INPUT,
result=self.ks1.model
)
self.ks1_x1 = self.ks1.insert_new('X1')
self.ks2_x1 = self.ks2.insert_new('X1')
self.inheritance = Inheritance.objects.create(
operation=self.operation,
parent=self.ks1_x1,
child=self.ks2_x1
)
def test_str(self):
testStr = f'{self.ks1_x1} -> {self.ks2_x1}'
self.assertEqual(str(self.inheritance), testStr)
def test_cascade_delete_operation(self):
self.assertEqual(Inheritance.objects.count(), 1)
self.operation.delete()
self.assertEqual(Inheritance.objects.count(), 0)
def test_cascade_delete_parent(self):
self.assertEqual(Inheritance.objects.count(), 1)
self.ks1_x1.delete()
self.assertEqual(Inheritance.objects.count(), 0)
def test_cascade_delete_child(self):
self.assertEqual(Inheritance.objects.count(), 1)
self.ks2_x1.delete()
self.assertEqual(Inheritance.objects.count(), 0)

View File

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

View File

@ -0,0 +1,125 @@
''' Testing API: Change attributes of OSS and RSForms. '''
from rest_framework import status
from apps.library.models import AccessPolicy, Editor, LocationHead
from apps.oss.models import Operation, OperationSchema, OperationType
from apps.rsform.models import RSForm
from apps.users.models import User
from shared.EndpointTester import EndpointTester, decl_endpoint
class TestChangeAttributes(EndpointTester):
''' Testing LibraryItem view when OSS is associated with RSForms. '''
def setUp(self):
super().setUp()
self.user3 = User.objects.create(
username='UserTest3',
email='anotheranother@test.com',
password='password'
)
self.owned = OperationSchema.create(
title='Test',
alias='T1',
owner=self.user,
location=LocationHead.LIBRARY
)
self.owned_id = self.owned.model.pk
self.ks1 = RSForm.create(
alias='KS1',
title='Test1',
owner=self.user,
location=LocationHead.USER
)
self.ks2 = RSForm.create(
alias='KS2',
title='Test2',
owner=self.user2,
location=LocationHead.LIBRARY
)
self.operation1 = self.owned.create_operation(
alias='1',
operation_type=OperationType.INPUT,
result=self.ks1.model
)
self.operation2 = self.owned.create_operation(
alias='2',
operation_type=OperationType.INPUT,
result=self.ks2.model
)
self.operation3 = self.owned.create_operation(
alias='3',
operation_type=OperationType.SYNTHESIS
)
self.owned.execute_operation(self.operation3)
self.operation3.refresh_from_db()
self.ks3 = self.operation3.result
@decl_endpoint('/api/library/{item}/set-owner', method='patch')
def test_set_owner(self):
data = {'user': self.user3.pk}
self.executeOK(data=data, item=self.owned_id)
self.owned.refresh_from_db()
self.ks1.refresh_from_db()
self.ks2.refresh_from_db()
self.ks3.refresh_from_db()
self.assertEqual(self.owned.model.owner, self.user3)
self.assertEqual(self.ks1.model.owner, self.user)
self.assertEqual(self.ks2.model.owner, self.user2)
self.assertEqual(self.ks3.owner, self.user3)
@decl_endpoint('/api/library/{item}/set-location', method='patch')
def test_set_location(self):
data = {'location': '/U/temp'}
self.executeOK(data=data, item=self.owned_id)
self.owned.refresh_from_db()
self.ks1.refresh_from_db()
self.ks2.refresh_from_db()
self.ks3.refresh_from_db()
self.assertEqual(self.owned.model.location, data['location'])
self.assertNotEqual(self.ks1.model.location, data['location'])
self.assertNotEqual(self.ks2.model.location, data['location'])
self.assertEqual(self.ks3.location, data['location'])
@decl_endpoint('/api/library/{item}/set-access-policy', method='patch')
def test_set_access_policy(self):
data = {'access_policy': AccessPolicy.PROTECTED}
self.executeOK(data=data, item=self.owned_id)
self.owned.refresh_from_db()
self.ks1.refresh_from_db()
self.ks2.refresh_from_db()
self.ks3.refresh_from_db()
self.assertEqual(self.owned.model.access_policy, data['access_policy'])
self.assertNotEqual(self.ks1.model.access_policy, data['access_policy'])
self.assertNotEqual(self.ks2.model.access_policy, data['access_policy'])
self.assertEqual(self.ks3.access_policy, data['access_policy'])
@decl_endpoint('/api/library/{item}/set-editors', method='patch')
def test_set_editors(self):
Editor.set(self.owned.model.pk, [self.user2.pk])
Editor.set(self.ks1.model.pk, [self.user2.pk, self.user.pk])
Editor.set(self.ks3.pk, [self.user2.pk, self.user.pk])
data = {'users': [self.user3.pk]}
self.executeOK(data=data, item=self.owned_id)
self.owned.refresh_from_db()
self.ks1.refresh_from_db()
self.ks2.refresh_from_db()
self.ks3.refresh_from_db()
self.assertEqual(list(self.owned.model.editors()), [self.user3])
self.assertEqual(list(self.ks1.model.editors()), [self.user, self.user2])
self.assertEqual(list(self.ks2.model.editors()), [])
self.assertEqual(set(self.ks3.editors()), set([self.user, self.user3]))

View File

@ -220,7 +220,7 @@ class TestOssViewset(EndpointTester):
@decl_endpoint('/api/oss/{item}/create-operation', method='post') @decl_endpoint('/api/oss/{item}/create-operation', method='post')
def test_create_operation_schema(self): def test_create_operation_schema(self):
self.populateData() self.populateData()
Editor.add(self.owned.model, self.user2) Editor.add(self.owned.model.pk, self.user2.pk)
data = { data = {
'item_data': { 'item_data': {
'alias': 'Test4', 'alias': 'Test4',

View File

@ -269,7 +269,7 @@ class CstRenameSerializer(serializers.Serializer):
class CstListSerializer(serializers.Serializer): class CstListSerializer(serializers.Serializer):
''' Serializer: List of constituents from one origin. ''' ''' Serializer: List of constituents from one origin. '''
items = PKField(many=True, queryset=Constituenta.objects.all()) items = PKField(many=True, queryset=Constituenta.objects.all().only('schema_id'))
def validate(self, attrs): def validate(self, attrs):
schema = cast(LibraryItem, self.context['schema']) schema = cast(LibraryItem, self.context['schema'])
@ -291,8 +291,8 @@ class CstMoveSerializer(CstListSerializer):
class SubstitutionSerializerBase(serializers.Serializer): class SubstitutionSerializerBase(serializers.Serializer):
''' Serializer: Basic substitution. ''' ''' Serializer: Basic substitution. '''
original = PKField(many=False, queryset=Constituenta.objects.only('alias', 'schema')) original = PKField(many=False, queryset=Constituenta.objects.only('alias', 'schema_id'))
substitution = PKField(many=False, queryset=Constituenta.objects.only('alias', 'schema')) substitution = PKField(many=False, queryset=Constituenta.objects.only('alias', 'schema_id'))
class CstSubstituteSerializer(serializers.Serializer): class CstSubstituteSerializer(serializers.Serializer):
@ -330,8 +330,8 @@ class CstSubstituteSerializer(serializers.Serializer):
class InlineSynthesisSerializer(serializers.Serializer): class InlineSynthesisSerializer(serializers.Serializer):
''' Serializer: Inline synthesis operation input. ''' ''' Serializer: Inline synthesis operation input. '''
receiver = PKField(many=False, queryset=LibraryItem.objects.all()) receiver = PKField(many=False, queryset=LibraryItem.objects.all().only('owner_id'))
source = PKField(many=False, queryset=LibraryItem.objects.all()) # type: ignore 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())
substitutions = serializers.ListField( substitutions = serializers.ListField(
child=SubstitutionSerializerBase() child=SubstitutionSerializerBase()

File diff suppressed because it is too large Load Diff

View File

@ -250,9 +250,8 @@ LOGGING = {
'root': { 'root': {
'handlers': ['console'], 'handlers': ['console'],
'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO') 'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO')
}, }
} }
if len(sys.argv) > 1 and sys.argv[1] == 'test': if len(sys.argv) > 1 and sys.argv[1] == 'test':
logging.disable(logging.CRITICAL) logging.disable(logging.CRITICAL)

View File

@ -1,4 +1,7 @@
''' Utils: base tester class for endpoints. ''' ''' Utils: base tester class for endpoints. '''
import logging
from django.db import connection
from rest_framework import status from rest_framework import status
from rest_framework.test import APIClient, APIRequestFactory, APITestCase from rest_framework.test import APIClient, APIRequestFactory, APITestCase
@ -40,6 +43,9 @@ class EndpointTester(APITestCase):
self.client = APIClient() self.client = APIClient()
self.client.force_authenticate(user=self.user) self.client.force_authenticate(user=self.user)
self.logger = logging.getLogger('django.db.backends')
self.logger.setLevel(logging.DEBUG)
def setUpFullUsers(self): def setUpFullUsers(self):
self.factory = APIRequestFactory() self.factory = APIRequestFactory()
self.user = User.objects.create_user( self.user = User.objects.create_user(
@ -61,9 +67,9 @@ class EndpointTester(APITestCase):
def toggle_editor(self, item: LibraryItem, value: bool = True): def toggle_editor(self, item: LibraryItem, value: bool = True):
if value: if value:
Editor.add(item, self.user) Editor.add(item.pk, self.user.pk)
else: else:
Editor.remove(item, self.user) Editor.remove(item.pk, self.user.pk)
def login(self): def login(self):
self.client.force_authenticate(user=self.user) self.client.force_authenticate(user=self.user)
@ -71,6 +77,16 @@ class EndpointTester(APITestCase):
def logout(self): def logout(self):
self.client.logout() self.client.logout()
def start_db_log(self):
''' Warning! Do not use this second time before calling stop_db_log. '''
''' Warning! Do not forget to enable global logging in settings. '''
logging.disable(logging.NOTSET)
connection.force_debug_cursor = True
def stop_db_log(self):
connection.force_debug_cursor = False
logging.disable(logging.CRITICAL)
def set_params(self, **kwargs): def set_params(self, **kwargs):
''' Given named argument values resolve current endpoint_mask. ''' ''' Given named argument values resolve current endpoint_mask. '''
if self.endpoint_mask and len(kwargs) > 0: if self.endpoint_mask and len(kwargs) > 0:

View File

@ -1,2 +0,0 @@
**/parser.ts
**/node_modules/**

View File

@ -1,25 +0,0 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended-type-checked",
"plugin:react-hooks/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module",
"project": ["tsconfig.json", "tsconfig.node.json"]
},
"plugins": ["react-refresh", "simple-import-sort", "eslint-plugin-tsdoc"],
"rules": {
"no-console": "off",
"require-jsdoc": "off",
"react-refresh/only-export-components": ["off", { "allowConstantExport": true }],
"simple-import-sort/imports": "warn",
"tsdoc/syntax": "warn"
}
}

View File

@ -10,4 +10,4 @@
"quoteProps": "consistent", "quoteProps": "consistent",
"bracketSameLine": false, "bracketSameLine": false,
"bracketSpacing": true "bracketSpacing": true
} }

View File

@ -0,0 +1,61 @@
import globals from 'globals';
import typescriptPlugin from 'typescript-eslint';
import typescriptParser from '@typescript-eslint/parser';
import reactPlugin from 'eslint-plugin-react';
// import { fixupPluginRules } from '@eslint/compat';
// import reactHooksPlugin from 'eslint-plugin-react-hooks';
import simpleImportSort from 'eslint-plugin-simple-import-sort';
export default [
...typescriptPlugin.configs.recommendedTypeChecked,
...typescriptPlugin.configs.stylisticTypeChecked,
{
ignores: ['**/parser.ts', '**/node_modules/**', '**/public/**', 'eslint.config.js']
},
{
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: { ...globals.browser, ...globals.es2020, ...globals.jest },
project: ['./tsconfig.json', './tsconfig.node.json']
}
}
},
{
plugins: {
'react': reactPlugin,
// 'react-hooks': fixupPluginRules(reactHooksPlugin),
'simple-import-sort': simpleImportSort
},
settings: { react: { version: 'detect' } },
rules: {
'@typescript-eslint/no-empty-object-type': ['error', { allowInterfaces: 'with-single-extends' }],
'@typescript-eslint/prefer-nullish-coalescing': 'off',
'@typescript-eslint/no-inferrable-types': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_'
}
],
'react-refresh/only-export-components': ['off', { allowConstantExport: true }],
'simple-import-sort/imports': 'warn',
'simple-import-sort/exports': 'error'
}
},
{
files: ['**/*.ts', '**/*.tsx'],
rules: {
'no-console': 'off',
'require-jsdoc': 'off'
}
}
];

File diff suppressed because it is too large Load Diff

View File

@ -8,17 +8,17 @@
"test": "jest", "test": "jest",
"dev": "vite --host", "dev": "vite --host",
"build": "tsc && vite build", "build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@lezer/lr": "^1.4.2", "@lezer/lr": "^1.4.2",
"@tanstack/react-table": "^8.19.3", "@tanstack/react-table": "^8.20.1",
"@uiw/codemirror-themes": "^4.23.0", "@uiw/codemirror-themes": "^4.23.0",
"@uiw/react-codemirror": "^4.23.0", "@uiw/react-codemirror": "^4.23.0",
"axios": "^1.7.2", "axios": "^1.7.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^11.3.19", "framer-motion": "^11.3.21",
"html-to-image": "^1.11.11", "html-to-image": "^1.11.11",
"js-file-download": "^0.4.12", "js-file-download": "^0.4.12",
"react": "^18.3.1", "react": "^18.3.1",
@ -28,11 +28,11 @@
"react-intl": "^6.6.8", "react-intl": "^6.6.8",
"react-loader-spinner": "^6.1.6", "react-loader-spinner": "^6.1.6",
"react-pdf": "^9.1.0", "react-pdf": "^9.1.0",
"react-router-dom": "^6.25.1", "react-router-dom": "^6.26.0",
"react-select": "^5.8.0", "react-select": "^5.8.0",
"react-tabs": "^6.0.2", "react-tabs": "^6.0.2",
"react-toastify": "^10.0.5", "react-toastify": "^10.0.5",
"react-tooltip": "^5.27.1", "react-tooltip": "^5.28.0",
"react-zoom-pan-pinch": "^3.6.1", "react-zoom-pan-pinch": "^3.6.1",
"reactflow": "^11.11.4", "reactflow": "^11.11.4",
"reagraph": "^4.19.2", "reagraph": "^4.19.2",
@ -41,23 +41,23 @@
"devDependencies": { "devDependencies": {
"@lezer/generator": "^1.7.1", "@lezer/generator": "^1.7.1",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/node": "^20.14.13", "@types/node": "^22.1.0",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/eslint-plugin": "^8.0.1",
"@typescript-eslint/parser": "^7.18.0", "@typescript-eslint/parser": "^8.0.1",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.20",
"eslint": "^8.57.0", "eslint": "^9.8.0",
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react": "^7.35.0",
"eslint-plugin-react-refresh": "^0.4.9",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-tsdoc": "^0.3.0", "globals": "^15.9.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"postcss": "^8.4.40", "postcss": "^8.4.40",
"tailwindcss": "^3.4.7", "tailwindcss": "^3.4.7",
"ts-jest": "^29.2.4", "ts-jest": "^29.2.4",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"typescript-eslint": "^8.0.1",
"vite": "^5.3.5" "vite": "^5.3.5"
}, },
"jest": { "jest": {

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 122 KiB

View File

@ -1,6 +1,7 @@
// Search new icons at https://reactsvgicons.com/ // Search new icons at https://reactsvgicons.com/
// Note: save this file using Ctrl + K, Ctrl + Shift + S to disable autoformat // Note: save this file using Ctrl + K, Ctrl + Shift + S to disable autoformat
/* eslint-disable simple-import-sort/exports */
// ==== General actions ======= // ==== General actions =======
export { BiMenu as IconMenu } from 'react-icons/bi'; export { BiMenu as IconMenu } from 'react-icons/bi';
export { LuLogOut as IconLogout } from 'react-icons/lu'; export { LuLogOut as IconLogout } from 'react-icons/lu';
@ -61,11 +62,11 @@ export { TbBriefcase as IconBusiness } from 'react-icons/tb';
export { VscLibrary as IconLibrary } from 'react-icons/vsc'; export { VscLibrary as IconLibrary } from 'react-icons/vsc';
export { IoLibrary as IconLibrary2 } from 'react-icons/io5'; export { IoLibrary as IconLibrary2 } from 'react-icons/io5';
export { BiDiamond as IconTemplates } from 'react-icons/bi'; export { BiDiamond as IconTemplates } from 'react-icons/bi';
export { GiHoneycomb as IconOSS } from 'react-icons/gi'; export { TbHexagons as IconOSS } from 'react-icons/tb';
export { LuBaby as IconChild } from 'react-icons/lu'; export { TbHexagon as IconRSForm } from 'react-icons/tb';
export { GrInherit as IconChild } from 'react-icons/gr';
export { RiParentLine as IconParent } from 'react-icons/ri'; export { RiParentLine as IconParent } from 'react-icons/ri';
export { BiSpa as IconPredecessor } from 'react-icons/bi'; export { BiSpa as IconPredecessor } from 'react-icons/bi';
export { RiHexagonLine as IconRSForm } from 'react-icons/ri';
export { LuArchive as IconArchive } from 'react-icons/lu'; export { LuArchive as IconArchive } from 'react-icons/lu';
export { LuDatabase as IconDatabase } from 'react-icons/lu'; export { LuDatabase as IconDatabase } from 'react-icons/lu';
export { LuView as IconDBStructure } from 'react-icons/lu'; export { LuView as IconDBStructure } from 'react-icons/lu';
@ -99,6 +100,7 @@ export { BiDownvote as IconMoveDown } from 'react-icons/bi';
export { BiRightArrow as IconMoveRight } from 'react-icons/bi'; export { BiRightArrow as IconMoveRight } from 'react-icons/bi';
export { BiLeftArrow as IconMoveLeft } from 'react-icons/bi'; export { BiLeftArrow as IconMoveLeft } from 'react-icons/bi';
export { FiBell as IconFollow } from 'react-icons/fi'; export { FiBell as IconFollow } from 'react-icons/fi';
export { TbHexagonPlus2 as IconNewRSForm } from 'react-icons/tb';
export { FiBellOff as IconFollowOff } from 'react-icons/fi'; export { FiBellOff as IconFollowOff } from 'react-icons/fi';
export { BiPlusCircle as IconNewItem } from 'react-icons/bi'; export { BiPlusCircle as IconNewItem } from 'react-icons/bi';
export { FaSquarePlus as IconNewItem2 } from 'react-icons/fa6'; export { FaSquarePlus as IconNewItem2 } from 'react-icons/fa6';

View File

@ -14,7 +14,7 @@ function InfoConstituenta({ data, className, ...restProps }: InfoConstituentaPro
return ( return (
<div className={clsx('dense min-w-[15rem]', className)} {...restProps}> <div className={clsx('dense min-w-[15rem]', className)} {...restProps}>
<h2> <h2>
Конституента {data.alias} {data.alias}
{data.is_inherited ? ' (наследуется)' : ''} {data.is_inherited ? ' (наследуется)' : ''}
</h2> </h2>
{data.term_resolved ? ( {data.term_resolved ? (

View File

@ -2,11 +2,11 @@
import { HTMLMotionProps } from 'framer-motion'; import { HTMLMotionProps } from 'framer-motion';
export namespace CProps { export namespace CProps {
export type Titled = { export interface Titled {
title?: string; title?: string;
titleHtml?: string; titleHtml?: string;
hideTitle?: boolean; hideTitle?: boolean;
}; }
export type Control = Titled & { export type Control = Titled & {
disabled?: boolean; disabled?: boolean;
@ -14,18 +14,18 @@ export namespace CProps {
noOutline?: boolean; noOutline?: boolean;
}; };
export type Styling = { export interface Styling {
style?: React.CSSProperties; style?: React.CSSProperties;
className?: string; className?: string;
}; }
export type Editor = Control & { export type Editor = Control & {
label?: string; label?: string;
}; };
export type Colors = { export interface Colors {
colors?: string; colors?: string;
}; }
export type Div = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>; export type Div = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>;
export type Button = Titled & export type Button = Titled &

View File

@ -17,25 +17,36 @@ interface MiniSelectorOSSProps {
function MiniSelectorOSS({ items, onSelect }: MiniSelectorOSSProps) { function MiniSelectorOSS({ items, onSelect }: MiniSelectorOSSProps) {
const ossMenu = useDropdown(); const ossMenu = useDropdown();
function onToggle(event: CProps.EventMouse) {
if (items.length > 1) {
ossMenu.toggle();
} else {
onSelect(event, items[0]);
}
}
return ( return (
<div ref={ossMenu.ref} className='flex items-center'> <div ref={ossMenu.ref} className='flex items-center'>
<MiniButton <MiniButton
icon={<IconOSS size='1.25rem' className='icon-primary' />} icon={<IconOSS size='1.25rem' className='icon-primary' />}
title='Связанные операционные схемы' title='Операционные схемы'
hideTitle={ossMenu.isOpen} hideTitle={ossMenu.isOpen}
onClick={() => ossMenu.toggle()} onClick={onToggle}
/> />
<Dropdown isOpen={ossMenu.isOpen}> {items.length > 1 ? (
<Label text='Список ОСС' className='border-b px-3 py-1' /> <Dropdown isOpen={ossMenu.isOpen}>
{items.map((reference, index) => ( <Label text='Список ОСС' className='border-b px-3 py-1' />
<DropdownButton {items.map((reference, index) => (
className='min-w-[5rem]' <DropdownButton
key={`${prefixes.oss_list}${index}`} className='min-w-[5rem]'
text={reference.alias} key={`${prefixes.oss_list}${index}`}
onClick={event => onSelect(event, reference)} text={reference.alias}
/> onClick={event => onSelect(event, reference)}
))} />
</Dropdown> ))}
</Dropdown>
) : null}
</div> </div>
); );
} }

View File

@ -140,13 +140,11 @@ function PickSubstitutions({
() => [ () => [
columnHelper.accessor(item => item.substitution_source?.alias ?? 'N/A', { columnHelper.accessor(item => item.substitution_source?.alias ?? 'N/A', {
id: 'left_schema', id: 'left_schema',
header: 'Операция',
size: 100, size: 100,
cell: props => <div className='min-w-[10.5rem] text-ellipsis text-right'>{props.getValue()}</div> cell: props => <div className='min-w-[10.5rem] text-ellipsis text-left'>{props.getValue()}</div>
}), }),
columnHelper.accessor(item => item.substitution?.alias ?? 'N/A', { columnHelper.accessor(item => item.substitution?.alias ?? 'N/A', {
id: 'left_alias', id: 'left_alias',
header: () => <span className='pl-3'>Имя</span>,
size: 65, size: 65,
cell: props => cell: props =>
props.row.original.substitution ? ( props.row.original.substitution ? (
@ -157,13 +155,11 @@ function PickSubstitutions({
}), }),
columnHelper.display({ columnHelper.display({
id: 'status', id: 'status',
header: '',
size: 40, size: 40,
cell: () => <IconPageRight size='1.2rem' /> cell: () => <IconPageRight size='1.2rem' />
}), }),
columnHelper.accessor(item => item.original?.alias ?? 'N/A', { columnHelper.accessor(item => item.original?.alias ?? 'N/A', {
id: 'right_alias', id: 'right_alias',
header: () => <span className='pl-3'>Имя</span>,
size: 65, size: 65,
cell: props => cell: props =>
props.row.original.original ? ( props.row.original.original ? (
@ -174,9 +170,8 @@ function PickSubstitutions({
}), }),
columnHelper.accessor(item => item.original_source?.alias ?? 'N/A', { columnHelper.accessor(item => item.original_source?.alias ?? 'N/A', {
id: 'right_schema', id: 'right_schema',
header: 'Операция',
size: 100, size: 100,
cell: props => <div className='min-w-[8rem] text-ellipsis'>{props.getValue()}</div> cell: props => <div className='min-w-[8rem] text-ellipsis text-right'>{props.getValue()}</div>
}), }),
columnHelper.display({ columnHelper.display({
id: 'actions', id: 'actions',

View File

@ -25,7 +25,7 @@ function SelectLocation({ value, folderTree, dense, prefix, onClick, className,
const [folded, setFolded] = useState<FolderNode[]>(items); const [folded, setFolded] = useState<FolderNode[]>(items);
useLayoutEffect(() => { useLayoutEffect(() => {
setFolded(items.filter(item => item !== activeNode && (!activeNode || !activeNode.hasPredecessor(item)))); setFolded(items.filter(item => item !== activeNode && !activeNode?.hasPredecessor(item)));
}, [items, activeNode]); }, [items, activeNode]);
const onFoldItem = useCallback( const onFoldItem = useCallback(

View File

@ -51,7 +51,7 @@ function SelectUser({
options={options} options={options}
value={value ? { value: value, label: getUserLabel(value) } : null} value={value ? { value: value, label: getUserLabel(value) } : null}
onChange={data => { onChange={data => {
if (data !== null && data.value !== undefined) onSelectValue(data.value); if (data?.value !== undefined) onSelectValue(data.value);
}} }}
// @ts-expect-error: TODO: use type definitions from react-select in filter object // @ts-expect-error: TODO: use type definitions from react-select in filter object
filterOption={filter} filterOption={filter}

View File

@ -23,7 +23,7 @@ import TableBody from './TableBody';
import TableFooter from './TableFooter'; import TableFooter from './TableFooter';
import TableHeader from './TableHeader'; import TableHeader from './TableHeader';
export { createColumnHelper, type ColumnSort, type RowSelectionState, type VisibilityState }; export { type ColumnSort, createColumnHelper, type RowSelectionState, type VisibilityState };
export interface IConditionalStyle<TData> { export interface IConditionalStyle<TData> {
when: (rowData: TData) => boolean; when: (rowData: TData) => boolean;

View File

@ -44,7 +44,7 @@ function TableBody<TData>({
lastIndex > currentIndex ? currentIndex : lastIndex + 1, lastIndex > currentIndex ? currentIndex : lastIndex + 1,
lastIndex > currentIndex ? lastIndex : currentIndex + 1 lastIndex > currentIndex ? lastIndex : currentIndex + 1
); );
const newSelection: { [key: string]: boolean } = {}; const newSelection: Record<string, boolean> = {};
toggleRows.forEach(row => { toggleRows.forEach(row => {
newSelection[row.id] = !target.getIsSelected(); newSelection[row.id] = !target.getIsSelected();
}); });

View File

@ -1,6 +1,6 @@
export { export {
default,
createColumnHelper, createColumnHelper,
default,
type IConditionalStyle, type IConditionalStyle,
type RowSelectionState, type RowSelectionState,
type VisibilityState type VisibilityState

View File

@ -4,12 +4,12 @@
import { GraphCanvas as GraphUI } from 'reagraph'; import { GraphCanvas as GraphUI } from 'reagraph';
export { export {
type CollapseProps,
type GraphCanvasRef,
type GraphEdge, type GraphEdge,
type GraphNode, type GraphNode,
type GraphCanvasRef,
Sphere, Sphere,
useSelection, useSelection
type CollapseProps
} from 'reagraph'; } from 'reagraph';
export { type LayoutTypes as GraphLayout } from 'reagraph'; export { type LayoutTypes as GraphLayout } from 'reagraph';

View File

@ -274,7 +274,7 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
onError: setProcessingError, onError: setProcessingError,
onSuccess: () => onSuccess: () =>
reloadItems(() => { reloadItems(() => {
if (user && user.subscriptions.includes(target)) { if (user?.subscriptions.includes(target)) {
user.subscriptions.splice( user.subscriptions.splice(
user.subscriptions.findIndex(item => item === target), user.subscriptions.findIndex(item => item === target),
1 1

View File

@ -102,7 +102,6 @@ export const OssState = ({ itemID, children }: OssStateProps) => {
return false; return false;
} }
return schema.subscribers.includes(user.id); return schema.subscribers.includes(user.id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, schema, toggleTracking]); }, [user, schema, toggleTracking]);
useEffect(() => { useEffect(() => {

View File

@ -143,7 +143,6 @@ export const RSFormState = ({ itemID, versionID, children }: RSFormStateProps) =
return false; return false;
} }
return schema.subscribers.includes(user.id); return schema.subscribers.includes(user.id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, schema, toggleTracking]); }, [user, schema, toggleTracking]);
const update = useCallback( const update = useCallback(

View File

@ -118,7 +118,7 @@ function DlgCreateOperation({ hideWindow, oss, onCreate, initialInputs }: DlgCre
/> />
</TabPanel> </TabPanel>
), ),
[alias, comment, title, attachedID, oss, createSchema] [alias, comment, title, attachedID, oss, createSchema, setAlias]
); );
const synthesisPanel = useMemo( const synthesisPanel = useMemo(
@ -137,7 +137,7 @@ function DlgCreateOperation({ hideWindow, oss, onCreate, initialInputs }: DlgCre
/> />
</TabPanel> </TabPanel>
), ),
[oss, alias, comment, title, inputs] [oss, alias, comment, title, inputs, setAlias]
); );
return ( return (

View File

@ -61,7 +61,6 @@ function DlgEditOperation({ hideWindow, oss, target, onSubmit }: DlgEditOperatio
useEffect(() => { useEffect(() => {
cache.preload(schemasIDs); cache.preload(schemasIDs);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schemasIDs]); }, [schemasIDs]);
const handleSubmit = () => { const handleSubmit = () => {
@ -92,7 +91,7 @@ function DlgEditOperation({ hideWindow, oss, target, onSubmit }: DlgEditOperatio
/> />
</TabPanel> </TabPanel>
), ),
[alias, comment, title] [alias, comment, title, setAlias]
); );
const argumentsPanel = useMemo( const argumentsPanel = useMemo(
@ -106,7 +105,7 @@ function DlgEditOperation({ hideWindow, oss, target, onSubmit }: DlgEditOperatio
/> />
</TabPanel> </TabPanel>
), ),
[oss, target, inputs] [oss, target, inputs, setInputs]
); );
const synthesisPanel = useMemo( const synthesisPanel = useMemo(

View File

@ -73,7 +73,6 @@ function useRSFormCache() {
} }
}) })
); );
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pending]); }, [pending]);
return { preload, getSchema, getConstituenta, getSchemaByCst, loading, error, setError }; return { preload, getSchema, getConstituenta, getSchemaByCst, loading, error, setError };

View File

@ -26,7 +26,6 @@ function useWindowSize() {
} }
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
return windowSize; return windowSize;

View File

@ -72,7 +72,7 @@ export class FolderNode {
* *
*/ */
export class FolderTree { export class FolderTree {
roots: Map<string, FolderNode> = new Map(); roots = new Map<string, FolderNode>();
constructor(arr?: string[]) { constructor(arr?: string[]) {
arr?.forEach(path => this.addPath(path)); arr?.forEach(path => this.addPath(path));

View File

@ -48,7 +48,7 @@ export class GraphNode {
* This class is optimized for TermGraph use case and not supposed to be used as generic graph implementation. * This class is optimized for TermGraph use case and not supposed to be used as generic graph implementation.
*/ */
export class Graph { export class Graph {
nodes: Map<number, GraphNode> = new Map(); nodes = new Map<number, GraphNode>();
constructor(arr?: number[][]) { constructor(arr?: number[][]) {
if (!arr) { if (!arr) {

View File

@ -20,7 +20,7 @@ import {
export class OssLoader { export class OssLoader {
private oss: IOperationSchemaData; private oss: IOperationSchemaData;
private graph: Graph = new Graph(); private graph: Graph = new Graph();
private operationByID: Map<OperationID, IOperation> = new Map(); private operationByID = new Map<OperationID, IOperation>();
private schemas: LibraryItemID[] = []; private schemas: LibraryItemID[] = [];
constructor(input: IOperationSchemaData) { constructor(input: IOperationSchemaData) {
@ -53,7 +53,7 @@ export class OssLoader {
} }
private extractSchemas() { private extractSchemas() {
this.schemas = this.oss.items.map(operation => operation.result as LibraryItemID).filter(item => item !== null); this.schemas = this.oss.items.map(operation => operation.result).filter(item => item !== null);
} }
private inferOperationAttributes() { private inferOperationAttributes() {

View File

@ -18,8 +18,8 @@ import { extractGlobals, isSimpleExpression, splitTemplateDefinition } from './r
export class RSFormLoader { export class RSFormLoader {
private schema: IRSFormData; private schema: IRSFormData;
private graph: Graph = new Graph(); private graph: Graph = new Graph();
private cstByAlias: Map<string, IConstituenta> = new Map(); private cstByAlias = new Map<string, IConstituenta>();
private cstByID: Map<ConstituentaID, IConstituenta> = new Map(); private cstByID = new Map<ConstituentaID, IConstituenta>();
constructor(input: IRSFormData) { constructor(input: IRSFormData) {
this.schema = input; this.schema = input;
@ -116,7 +116,7 @@ export class RSFormLoader {
} }
private extractSources(target: IConstituenta): Set<ConstituentaID> { private extractSources(target: IConstituenta): Set<ConstituentaID> {
const sources: Set<ConstituentaID> = new Set(); const sources = new Set<ConstituentaID>();
if (!isFunctional(target.cst_type)) { if (!isFunctional(target.cst_type)) {
const node = this.graph.at(target.id)!; const node = this.graph.at(target.id)!;
node.inputs.forEach(id => { node.inputs.forEach(id => {

View File

@ -103,6 +103,7 @@ export interface ILibraryItemEditor {
isMutable: boolean; isMutable: boolean;
isProcessing: boolean; isProcessing: boolean;
isAttachedToOSS: boolean;
setOwner: (newOwner: UserID) => void; setOwner: (newOwner: UserID) => void;
setAccessPolicy: (newPolicy: AccessPolicy) => void; setAccessPolicy: (newPolicy: AccessPolicy) => void;

View File

@ -108,7 +108,7 @@ export enum HelpTopic {
/** /**
* Manual topics hierarchy. * Manual topics hierarchy.
*/ */
export const topicParent: Map<HelpTopic, HelpTopic> = new Map([ export const topicParent = new Map<HelpTopic, HelpTopic>([
[HelpTopic.MAIN, HelpTopic.MAIN], [HelpTopic.MAIN, HelpTopic.MAIN],
[HelpTopic.INTERFACE, HelpTopic.INTERFACE], [HelpTopic.INTERFACE, HelpTopic.INTERFACE],

View File

@ -254,7 +254,7 @@ export function isFunctional(type: CstType): boolean {
* Validate new alias against {@link CstType} and {@link IRSForm}. * Validate new alias against {@link CstType} and {@link IRSForm}.
*/ */
export function validateNewAlias(alias: string, type: CstType, schema: IRSForm): boolean { export function validateNewAlias(alias: string, type: CstType, schema: IRSForm): boolean {
return alias.length >= 2 && alias[0] == getCstTypePrefix(type) && !schema.cstByAlias.has(alias); return alias.length >= 2 && alias.startsWith(getCstTypePrefix(type)) && !schema.cstByAlias.has(alias);
} }
/** /**

View File

@ -91,7 +91,7 @@ export function substituteTemplateArgs(expression: string, args: IArgumentValue[
return expression; return expression;
} }
const mapping: { [key: string]: string } = {}; const mapping: Record<string, string> = {};
args args
.filter(arg => !!arg.value) .filter(arg => !!arg.value)
.forEach(arg => { .forEach(arg => {

View File

@ -46,7 +46,7 @@ function FormCreateItem() {
const location = useMemo(() => combineLocation(head, body), [head, body]); const location = useMemo(() => combineLocation(head, body), [head, body]);
const isValid = useMemo(() => validateLocation(location), [location]); const isValid = useMemo(() => validateLocation(location), [location]);
const [initLocation] = useLocalStorage<string>(storage.librarySearchLocation, ''); const [initLocation, setInitLocation] = useLocalStorage<string>(storage.librarySearchLocation, '');
const [fileName, setFileName] = useState(''); const [fileName, setFileName] = useState('');
const [file, setFile] = useState<File | undefined>(); const [file, setFile] = useState<File | undefined>();
@ -81,6 +81,7 @@ function FormCreateItem() {
file: file, file: file,
fileName: file?.name fileName: file?.name
}; };
setInitLocation(location);
createItem(data, newItem => { createItem(data, newItem => {
toast.success(information.newLibraryItem); toast.success(information.newLibraryItem);
if (itemType == LibraryItemType.RSFORM) { if (itemType == LibraryItemType.RSFORM) {

View File

@ -1,6 +1,5 @@
import { HelpTopic } from '@/models/miscellaneous';
import LinkTopic from '@/components/ui/LinkTopic'; import LinkTopic from '@/components/ui/LinkTopic';
import { HelpTopic } from '@/models/miscellaneous';
function HelpCstAttributes() { function HelpCstAttributes() {
return ( return (

View File

@ -1,15 +1,19 @@
import { import {
IconChild,
IconClone, IconClone,
IconControls, IconControls,
IconDestroy, IconDestroy,
IconEdit, IconEdit,
IconFilter,
IconList, IconList,
IconMoveDown, IconMoveDown,
IconMoveUp, IconMoveUp,
IconNewItem, IconNewItem,
IconOSS, IconOSS,
IconPredecessor,
IconReset, IconReset,
IconSave, IconSave,
IconSettings,
IconStatusOK, IconStatusOK,
IconText, IconText,
IconTree IconTree
@ -23,37 +27,64 @@ function HelpCstEditor() {
return ( return (
<div className='dense'> <div className='dense'>
<h1>Редактор конституенты</h1> <h1>Редактор конституенты</h1>
<li> <div className='flex flex-col sm:flex-row sm:gap-3'>
<IconOSS className='inline-icon' /> переход к связанной <LinkTopic text='ОСС' topic={HelpTopic.CC_OSS} /> <div className='flex flex-col'>
</li> <li>
<li> <IconOSS className='inline-icon' /> переход к <LinkTopic text='ОСС' topic={HelpTopic.CC_OSS} />
<IconSave className='inline-icon' /> сохранить изменения: Ctrl + S </li>
</li> <li>
<li> <IconPredecessor className='inline-icon' /> переход к исходной
<IconReset className='inline-icon' /> сбросить несохраненные изменения </li>
</li> <li>
<li> <IconList className='inline-icon' /> список конституент
<IconClone className='inline-icon icon-green' /> клонировать текущую: Alt + V </li>
</li> <li>
<li> <IconSave className='inline-icon' /> сохранить: Ctrl + S
<IconNewItem className='inline-icon icon-green' /> новая конституента </li>
</li> <li>
<li> <IconReset className='inline-icon' /> сбросить изменения
<IconDestroy className='inline-icon icon-red' /> удаление текущей </li>
</li> <li>
<IconClone className='inline-icon icon-green' /> клонировать: Alt + V
</li>
<li>
<IconNewItem className='inline-icon icon-green' /> новая конституента
</li>
<li>
<IconDestroy className='inline-icon icon-red' /> удалить
</li>
</div>
<h2>Термин и Текстовое определение</h2> <div className='flex flex-col'>
<li> <h2>Список конституент</h2>
<IconEdit className='inline-icon' /> кнопка переименования справа от{' '} <li>
<LinkTopic text='Имени' topic={HelpTopic.CC_CONSTITUENTA} /> <IconMoveDown className='inline-icon' />
</li> <IconMoveUp className='inline-icon' /> Alt + вверх/вниз
<li> </li>
<IconEdit className='inline-icon' /> кнопка редактирования словоформ справа от{' '} <li>
<LinkTopic text='Термина' topic={HelpTopic.CC_CONSTITUENTA} /> <IconFilter className='inline-icon' />
</li> <IconSettings className='inline-icon' /> фильтрация по графу термов
<li>Ctrl + Пробел открывает редактирование отсылок</li> </li>
<li>
<IconChild className='inline-icon' /> отображение наследованных
</li>
<li>
<span style={{ backgroundColor: colors.bgSelected }}>текущая конституента</span>
</li>
<li>
<span style={{ backgroundColor: colors.bgGreen50 }}>
<LinkTopic text='основа' topic={HelpTopic.CC_RELATIONS} /> текущей
</span>
</li>
<li>
<span style={{ backgroundColor: colors.bgOrange50 }}>
<LinkTopic text='порожденные' topic={HelpTopic.CC_RELATIONS} /> текущей
</span>
</li>
</div>
</div>
<h2>Определение понятия</h2> <h2>Формальное определение</h2>
<li> <li>
<IconStatusOK className='inline-icon' /> индикатор статуса определения сверху <IconStatusOK className='inline-icon' /> индикатор статуса определения сверху
</li> </li>
@ -69,26 +100,12 @@ function HelpCstEditor() {
</li> </li>
<li>Ctrl + Пробел дополняет до незанятого имени</li> <li>Ctrl + Пробел дополняет до незанятого имени</li>
<h2>Список конституент</h2> <h2>Термин и Текстовое определение</h2>
<li> <li>
<IconList className='inline-icon' /> отображение списка конституент <IconEdit className='inline-icon' /> редактирование <LinkTopic text='Имени' topic={HelpTopic.CC_CONSTITUENTA} />{' '}
</li> / <LinkTopic text='Термина' topic={HelpTopic.CC_CONSTITUENTA} />
<li>
<IconMoveDown className='inline-icon' />
<IconMoveUp className='inline-icon' /> Alt + вверх/вниз перемещение
</li>
<li>фильтрация в верхней части</li>
<li>
<span style={{ backgroundColor: colors.bgSelected }}>цветом фона</span> выделена текущая конституента
</li>
<li>
<span style={{ backgroundColor: colors.bgGreen50 }}>цветом фона</span> выделена{' '}
<LinkTopic text='основа' topic={HelpTopic.CC_RELATIONS} /> текущей
</li>
<li>
<span style={{ backgroundColor: colors.bgOrange50 }}>цветом фона</span> выделены{' '}
<LinkTopic text='порожденные' topic={HelpTopic.CC_RELATIONS} /> текущей
</li> </li>
<li>Ctrl + Пробел открывает редактирование отсылок</li>
</div> </div>
); );
} }

View File

@ -1,8 +1,101 @@
import {
IconAnimation,
IconAnimationOff,
IconConnect,
IconDestroy,
IconEdit2,
IconExecute,
IconFitImage,
IconGrid,
IconImage,
IconLineStraight,
IconLineWave,
IconNewItem,
IconNewRSForm,
IconReset,
IconRSForm,
IconSave
} from '@/components/Icons';
import Divider from '@/components/ui/Divider';
import LinkTopic from '@/components/ui/LinkTopic';
import { HelpTopic } from '@/models/miscellaneous';
function HelpOssGraph() { function HelpOssGraph() {
return ( return (
<div> <div className='flex flex-col'>
<h1>Граф синтеза</h1> <h1>Граф синтеза</h1>
<p>TBD.</p> <div className='flex flex-col sm:flex-row'>
<div className='w-full sm:w-[14rem]'>
<h1>Настройка графа</h1>
<li>
<IconFitImage className='inline-icon' /> Вписать в экран
</li>
<li>
<IconGrid className='inline-icon' /> Отображение сетки
</li>
<li>
<IconLineWave className='inline-icon' />
<IconLineStraight className='inline-icon' /> Тип линии
</li>
<li>
<IconAnimation className='inline-icon' />
<IconAnimationOff className='inline-icon' /> Анимация
</li>
</div>
<Divider vertical margins='mx-3 mt-3' className='hidden sm:block' />
<div className='w-full sm:w-[21rem]'>
<h1>Изменение узлов</h1>
<li>Клик на операцию выделение</li>
<li>Esc сбросить выделение</li>
<li>
<IconEdit2 className='inline-icon' /> Двойной клик редактирование
</li>
<li>
<IconNewItem className='inline-icon icon-green' /> Новая операция
</li>
<li>
<IconDestroy className='inline-icon icon-red' /> Delete удалить выбранные
</li>
</div>
</div>
<Divider margins='my-3' className='hidden sm:block' />
<div className='flex flex-col-reverse mb-3 sm:flex-row'>
<div className='w-full sm:w-[14rem]'>
<h1>Общие</h1>
<li>
<IconReset className='inline-icon' /> Сбросить изменения
</li>
<li>
<IconSave className='inline-icon' /> Сохранить положения
</li>
<li>
<IconImage className='inline-icon' /> Сохранить в формат SVG
</li>
</div>
<Divider vertical margins='mx-3' className='hidden sm:block' />
<div className='dense w-[21rem]'>
<h1>Контекстное меню</h1>
<li>
<IconRSForm className='inline-icon icon-green' /> Переход к связанной{' '}
<LinkTopic text='КС' topic={HelpTopic.CC_SYSTEM} />
</li>
<li>
<IconNewRSForm className='inline-icon icon-green' /> Создать пустую КС для загрузки
</li>
<li>
<IconConnect className='inline-icon' /> Выбрать КС для загрузки
</li>
<li>
<IconExecute className='inline-icon icon-green' /> Выполнить (активировать) операцию
</li>
</div>
</div>
</div> </div>
); );
} }

View File

@ -26,6 +26,7 @@ function HelpTermGraph() {
const { colors } = useConceptOptions(); const { colors } = useConceptOptions();
return ( return (
<div className='flex flex-col'> <div className='flex flex-col'>
<h1>Граф термов</h1>
<div className='flex flex-col sm:flex-row'> <div className='flex flex-col sm:flex-row'>
<div className='w-full sm:w-[14rem]'> <div className='w-full sm:w-[14rem]'>
<h1>Настройка графа</h1> <h1>Настройка графа</h1>
@ -78,7 +79,7 @@ function HelpTermGraph() {
<IconFilter className='inline-icon' /> Открыть настройки <IconFilter className='inline-icon' /> Открыть настройки
</li> </li>
<li> <li>
<IconFitImage className='inline-icon' /> Вписать граф в экран <IconFitImage className='inline-icon' /> Вписать в экран
</li> </li>
<li> <li>
<IconImage className='inline-icon' /> Сохранить в формат PNG <IconImage className='inline-icon' /> Сохранить в формат PNG

View File

@ -33,7 +33,7 @@ function InputNode(node: OssNodeInternal) {
disabled={!hasFile} disabled={!hasFile}
/> />
</Overlay> </Overlay>
<div id={`${prefixes.operation_list}${node.id}`} className='flex-grow text-center text-sm'> <div id={`${prefixes.operation_list}${node.id}`} className='flex-grow text-center'>
{node.data.label} {node.data.label}
{controller.showTooltip && !node.dragging ? ( {controller.showTooltip && !node.dragging ? (
<TooltipOperation anchor={`#${prefixes.operation_list}${node.id}`} node={node} /> <TooltipOperation anchor={`#${prefixes.operation_list}${node.id}`} node={node} />

View File

@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { IconConnect, IconDestroy, IconEdit2, IconExecute, IconNewItem, IconRSForm } from '@/components/Icons'; import { IconConnect, IconDestroy, IconEdit2, IconExecute, IconNewRSForm, IconRSForm } from '@/components/Icons';
import Dropdown from '@/components/ui/Dropdown'; import Dropdown from '@/components/ui/Dropdown';
import DropdownButton from '@/components/ui/DropdownButton'; import DropdownButton from '@/components/ui/DropdownButton';
import useClickedOutside from '@/hooks/useClickedOutside'; import useClickedOutside from '@/hooks/useClickedOutside';
@ -124,7 +124,7 @@ function NodeContextMenu({
<DropdownButton <DropdownButton
text='Создать схему' text='Создать схему'
title='Создать пустую схему для загрузки' title='Создать пустую схему для загрузки'
icon={<IconNewItem size='1rem' className='icon-green' />} icon={<IconNewRSForm size='1rem' className='icon-green' />}
disabled={controller.isProcessing} disabled={controller.isProcessing}
onClick={handleCreateSchema} onClick={handleCreateSchema}
/> />

View File

@ -33,7 +33,7 @@ function OperationNode(node: OssNodeInternal) {
/> />
</Overlay> </Overlay>
<div id={`${prefixes.operation_list}${node.id}`} className='flex-grow text-center text-sm'> <div id={`${prefixes.operation_list}${node.id}`} className='flex-grow text-center'>
{node.data.label} {node.data.label}
{controller.showTooltip && !node.dragging ? ( {controller.showTooltip && !node.dragging ? (
<TooltipOperation anchor={`#${prefixes.operation_list}${node.id}`} node={node} /> <TooltipOperation anchor={`#${prefixes.operation_list}${node.id}`} node={node} />

View File

@ -24,7 +24,7 @@ import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { useOSS } from '@/context/OssContext'; import { useOSS } from '@/context/OssContext';
import useLocalStorage from '@/hooks/useLocalStorage'; import useLocalStorage from '@/hooks/useLocalStorage';
import { OssNode } from '@/models/miscellaneous'; import { OssNode } from '@/models/miscellaneous';
import { OperationID } from '@/models/oss'; import { OperationID, OperationType } from '@/models/oss';
import { PARAMETER, storage } from '@/utils/constants'; import { PARAMETER, storage } from '@/utils/constants';
import { errors } from '@/utils/labels'; import { errors } from '@/utils/labels';
@ -127,19 +127,32 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
if (!controller.schema) { if (!controller.schema) {
return; return;
} }
let target = { x: 0, y: 0 }; let target = { x: 0, y: 0 };
const positions = getPositions(); const positions = getPositions();
if (positions.length == 0) {
if (inputs.length <= 1) {
target = flow.project({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); target = flow.project({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
}
if (inputs.length <= 1) {
let inputsNodes = positions.filter(pos =>
controller.schema!.items.find(
operation => operation.operation_type === OperationType.INPUT && operation.id === pos.id
)
);
if (inputsNodes.length > 0) {
inputsNodes = positions;
}
const maxX = Math.max(...inputsNodes.map(node => node.position_x));
const minY = Math.min(...inputsNodes.map(node => node.position_y));
target.x = maxX + 180;
target.y = minY;
} else { } else {
const inputsNodes = positions.filter(pos => inputs.includes(pos.id)); const inputsNodes = positions.filter(pos => inputs.includes(pos.id));
const maxY = Math.max(...inputsNodes.map(node => node.position_y)); const maxY = Math.max(...inputsNodes.map(node => node.position_y));
const minX = Math.min(...inputsNodes.map(node => node.position_x)); const minX = Math.min(...inputsNodes.map(node => node.position_x));
const maxX = Math.max(...inputsNodes.map(node => node.position_x)); const maxX = Math.max(...inputsNodes.map(node => node.position_x));
target.y = maxY + 100;
target.x = Math.ceil((maxX + minX) / 2 / PARAMETER.ossGridSize) * PARAMETER.ossGridSize; target.x = Math.ceil((maxX + minX) / 2 / PARAMETER.ossGridSize) * PARAMETER.ossGridSize;
target.y = maxY + 100;
} }
let flagIntersect = false; let flagIntersect = false;
@ -154,8 +167,13 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
target.y += PARAMETER.ossMinDistance; target.y += PARAMETER.ossMinDistance;
} }
} while (flagIntersect); } while (flagIntersect);
controller.promptCreateOperation({
controller.promptCreateOperation(target.x, target.y, inputs, positions); x: target.x,
y: target.y,
inputs: inputs,
positions: positions,
callback: () => flow.fitView({ duration: PARAMETER.zoomDuration })
});
}, },
[controller, getPositions, flow] [controller, getPositions, flow]
); );

View File

@ -15,7 +15,7 @@ import DlgChangeLocation from '@/dialogs/DlgChangeLocation';
import DlgCreateOperation from '@/dialogs/DlgCreateOperation'; import DlgCreateOperation from '@/dialogs/DlgCreateOperation';
import DlgEditEditors from '@/dialogs/DlgEditEditors'; import DlgEditEditors from '@/dialogs/DlgEditEditors';
import DlgEditOperation from '@/dialogs/DlgEditOperation'; import DlgEditOperation from '@/dialogs/DlgEditOperation';
import { AccessPolicy, LibraryItemID } from '@/models/library'; import { AccessPolicy, ILibraryItemEditor, LibraryItemID } from '@/models/library';
import { Position2D } from '@/models/miscellaneous'; import { Position2D } from '@/models/miscellaneous';
import { import {
IOperationCreateData, IOperationCreateData,
@ -26,14 +26,24 @@ import {
OperationID OperationID
} from '@/models/oss'; } from '@/models/oss';
import { UserID, UserLevel } from '@/models/user'; import { UserID, UserLevel } from '@/models/user';
import { PARAMETER } from '@/utils/constants';
import { information } from '@/utils/labels'; import { information } from '@/utils/labels';
export interface IOssEditContext { export interface ICreateOperationPrompt {
x: number;
y: number;
inputs: OperationID[];
positions: IOperationPosition[];
callback: (newID: OperationID) => void;
}
export interface IOssEditContext extends ILibraryItemEditor {
schema?: IOperationSchema; schema?: IOperationSchema;
selected: OperationID[]; selected: OperationID[];
isMutable: boolean; isMutable: boolean;
isProcessing: boolean; isProcessing: boolean;
isAttachedToOSS: boolean;
showTooltip: boolean; showTooltip: boolean;
setShowTooltip: React.Dispatch<React.SetStateAction<boolean>>; setShowTooltip: React.Dispatch<React.SetStateAction<boolean>>;
@ -51,7 +61,7 @@ export interface IOssEditContext {
openOperationSchema: (target: OperationID) => void; openOperationSchema: (target: OperationID) => void;
savePositions: (positions: IOperationPosition[], callback?: () => void) => void; savePositions: (positions: IOperationPosition[], callback?: () => void) => void;
promptCreateOperation: (x: number, y: number, inputs: OperationID[], positions: IOperationPosition[]) => void; promptCreateOperation: (props: ICreateOperationPrompt) => void;
deleteOperation: (target: OperationID, positions: IOperationPosition[]) => void; deleteOperation: (target: OperationID, positions: IOperationPosition[]) => void;
createInput: (target: OperationID, positions: IOperationPosition[]) => void; createInput: (target: OperationID, positions: IOperationPosition[]) => void;
promptEditInput: (target: OperationID, positions: IOperationPosition[]) => void; promptEditInput: (target: OperationID, positions: IOperationPosition[]) => void;
@ -97,6 +107,8 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
const [showCreateOperation, setShowCreateOperation] = useState(false); const [showCreateOperation, setShowCreateOperation] = useState(false);
const [insertPosition, setInsertPosition] = useState<Position2D>({ x: 0, y: 0 }); const [insertPosition, setInsertPosition] = useState<Position2D>({ x: 0, y: 0 });
const [initialInputs, setInitialInputs] = useState<OperationID[]>([]); const [initialInputs, setInitialInputs] = useState<OperationID[]>([]);
const [createCallback, setCreateCallback] = useState<((newID: OperationID) => void) | undefined>(undefined);
const [positions, setPositions] = useState<IOperationPosition[]>([]); const [positions, setPositions] = useState<IOperationPosition[]>([]);
const [targetOperationID, setTargetOperationID] = useState<OperationID | undefined>(undefined); const [targetOperationID, setTargetOperationID] = useState<OperationID | undefined>(undefined);
const targetOperation = useMemo( const targetOperation = useMemo(
@ -184,7 +196,7 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
const openOperationSchema = useCallback( const openOperationSchema = useCallback(
(target: OperationID) => { (target: OperationID) => {
const node = model.schema?.operationByID.get(target); const node = model.schema?.operationByID.get(target);
if (!node || !node.result) { if (!node?.result) {
return; return;
} }
router.push(urls.schema(node.result)); router.push(urls.schema(node.result));
@ -209,24 +221,27 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
[model] [model]
); );
const promptCreateOperation = useCallback( const promptCreateOperation = useCallback(({ x, y, inputs, positions, callback }: ICreateOperationPrompt) => {
(x: number, y: number, inputs: OperationID[], positions: IOperationPosition[]) => { setInsertPosition({ x: x, y: y });
setInsertPosition({ x: x, y: y }); setInitialInputs(inputs);
setInitialInputs(inputs); setPositions(positions);
setPositions(positions); setCreateCallback(() => callback);
setShowCreateOperation(true); setShowCreateOperation(true);
}, }, []);
[]
);
const handleCreateOperation = useCallback( const handleCreateOperation = useCallback(
(data: IOperationCreateData) => { (data: IOperationCreateData) => {
data.positions = positions; data.positions = positions;
data.item_data.position_x = insertPosition.x; data.item_data.position_x = insertPosition.x;
data.item_data.position_y = insertPosition.y; data.item_data.position_y = insertPosition.y;
model.createOperation(data, operation => toast.success(information.newOperation(operation.alias))); model.createOperation(data, operation => {
toast.success(information.newOperation(operation.alias));
if (createCallback) {
setTimeout(() => createCallback(operation.id), PARAMETER.refreshTimeout);
}
});
}, },
[model, positions, insertPosition] [model, positions, insertPosition, createCallback]
); );
const promptEditOperation = useCallback((target: OperationID, positions: IOperationPosition[]) => { const promptEditOperation = useCallback((target: OperationID, positions: IOperationPosition[]) => {
@ -305,6 +320,7 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
isMutable, isMutable,
isProcessing: model.processing, isProcessing: model.processing,
isAttachedToOSS: false,
toggleSubscribe, toggleSubscribe,
setOwner, setOwner,

View File

@ -135,7 +135,7 @@ function OssTabs() {
<TabList className={clsx('mx-auto w-fit', 'flex items-stretch', 'border-b-2 border-x-2 divide-x-2')}> <TabList className={clsx('mx-auto w-fit', 'flex items-stretch', 'border-b-2 border-x-2 divide-x-2')}>
<MenuOssTabs onDestroy={onDestroySchema} /> <MenuOssTabs onDestroy={onDestroySchema} />
<TabLabel label='Карточка' titleHtml={`Название: <b>${schema.title ?? ''}</b>`} /> <TabLabel label='Карточка' title={schema.title ?? ''} />
<TabLabel label='Граф' /> <TabLabel label='Граф' />
</TabList> </TabList>

View File

@ -1,5 +1,7 @@
'use client'; 'use client';
import clsx from 'clsx';
import { import {
IconClone, IconClone,
IconDestroy, IconDestroy,
@ -54,7 +56,7 @@ function ToolbarConstituenta({
onSelect={(event, value) => controller.viewOSS(value.id, event.ctrlKey || event.metaKey)} onSelect={(event, value) => controller.viewOSS(value.id, event.ctrlKey || event.metaKey)}
/> />
) : null} ) : null}
{activeCst && activeCst.is_inherited ? ( {activeCst?.is_inherited ? (
<MiniButton <MiniButton
title='Перейти к исходной конституенте в ОСС' title='Перейти к исходной конституенте в ОСС'
onClick={() => controller.viewPredecessor(activeCst.id)} onClick={() => controller.viewPredecessor(activeCst.id)}
@ -118,7 +120,11 @@ function ToolbarConstituenta({
/> />
</> </>
) : null} ) : null}
<BadgeHelp topic={HelpTopic.UI_RS_EDITOR} offset={4} className={PARAMETER.TOOLTIP_WIDTH} /> <BadgeHelp
topic={HelpTopic.UI_RS_EDITOR}
offset={4}
className={clsx(PARAMETER.TOOLTIP_WIDTH, 'sm:max-w-[40rem]')}
/>
</Overlay> </Overlay>
); );
} }

View File

@ -117,7 +117,7 @@ function EditorRSExpression({
); );
const handleEdit = useCallback((id: TokenID, key?: string) => { const handleEdit = useCallback((id: TokenID, key?: string) => {
if (!rsInput.current || !rsInput.current.editor || !rsInput.current.state || !rsInput.current.view) { if (!rsInput.current?.editor || !rsInput.current.state || !rsInput.current.view) {
return; return;
} }
const text = new RSTextWrapper(rsInput.current as Required<ReactCodeMirrorRef>); const text = new RSTextWrapper(rsInput.current as Required<ReactCodeMirrorRef>);
@ -145,12 +145,12 @@ function EditorRSExpression({
const controls = useMemo( const controls = useMemo(
() => ( () => (
<RSEditorControls <RSEditorControls
isOpen={showControls && (!disabled || model.processing)} isOpen={showControls && (!disabled || (model.processing && !activeCst?.is_inherited))}
disabled={disabled} disabled={disabled}
onEdit={handleEdit} onEdit={handleEdit}
/> />
), ),
[showControls, disabled, model.processing, handleEdit] [showControls, disabled, model.processing, handleEdit, activeCst]
); );
return ( return (

View File

@ -4,6 +4,7 @@ import { useIntl } from 'react-intl';
import { IconEdit } from '@/components/Icons'; import { IconEdit } from '@/components/Icons';
import InfoUsers from '@/components/info/InfoUsers'; import InfoUsers from '@/components/info/InfoUsers';
import SelectUser from '@/components/select/SelectUser'; import SelectUser from '@/components/select/SelectUser';
import LabeledValue from '@/components/ui/LabeledValue';
import MiniButton from '@/components/ui/MiniButton'; import MiniButton from '@/components/ui/MiniButton';
import Overlay from '@/components/ui/Overlay'; import Overlay from '@/components/ui/Overlay';
import Tooltip from '@/components/ui/Tooltip'; import Tooltip from '@/components/ui/Tooltip';
@ -15,8 +16,6 @@ import { UserID, UserLevel } from '@/models/user';
import { prefixes } from '@/utils/constants'; import { prefixes } from '@/utils/constants';
import { prompts } from '@/utils/labels'; import { prompts } from '@/utils/labels';
import LabeledValue from '@/components/ui/LabeledValue';
interface EditorLibraryItemProps { interface EditorLibraryItemProps {
item?: ILibraryItemData; item?: ILibraryItemData;
isModified?: boolean; isModified?: boolean;
@ -48,11 +47,11 @@ function EditorLibraryItem({ item, isModified, controller }: EditorLibraryItemPr
{accessLevel >= UserLevel.OWNER ? ( {accessLevel >= UserLevel.OWNER ? (
<Overlay position='top-[-0.5rem] left-[2.3rem] cc-icons'> <Overlay position='top-[-0.5rem] left-[2.3rem] cc-icons'>
<MiniButton <MiniButton
title='Изменить путь' title={controller.isAttachedToOSS ? 'Путь наследуется от ОСС' : 'Изменить путь'}
noHover noHover
onClick={() => controller.promptLocation()} onClick={() => controller.promptLocation()}
icon={<IconEdit size='1rem' className='mt-1 icon-primary' />} icon={<IconEdit size='1rem' className='mt-1 icon-primary' />}
disabled={isModified || controller.isProcessing} disabled={isModified || controller.isProcessing || controller.isAttachedToOSS}
/> />
</Overlay> </Overlay>
) : null} ) : null}
@ -66,11 +65,11 @@ function EditorLibraryItem({ item, isModified, controller }: EditorLibraryItemPr
<Overlay position='top-[-0.5rem] left-[5.5rem] cc-icons'> <Overlay position='top-[-0.5rem] left-[5.5rem] cc-icons'>
<div className='flex items-start'> <div className='flex items-start'>
<MiniButton <MiniButton
title='Изменить владельца' title={controller.isAttachedToOSS ? 'Владелец наследуется от ОСС' : 'Изменить владельца'}
noHover noHover
onClick={() => ownerSelector.toggle()} onClick={() => ownerSelector.toggle()}
icon={<IconEdit size='1rem' className='mt-1 icon-primary' />} icon={<IconEdit size='1rem' className='mt-1 icon-primary' />}
disabled={isModified || controller.isProcessing} disabled={isModified || controller.isProcessing || controller.isAttachedToOSS}
/> />
{ownerSelector.isOpen ? ( {ownerSelector.isOpen ? (
<SelectUser <SelectUser

View File

@ -33,7 +33,7 @@ function ToolbarItemAccess({ visible, toggleVisible, readOnly, toggleReadOnly, c
<Label text='Доступ' className='self-center select-none' /> <Label text='Доступ' className='self-center select-none' />
<div className='ml-auto cc-icons'> <div className='ml-auto cc-icons'>
<SelectAccessPolicy <SelectAccessPolicy
disabled={accessLevel <= UserLevel.EDITOR || controller.isProcessing} disabled={accessLevel <= UserLevel.EDITOR || controller.isProcessing || controller.isAttachedToOSS}
value={policy} value={policy}
onChange={newPolicy => controller.setAccessPolicy(newPolicy)} onChange={newPolicy => controller.setAccessPolicy(newPolicy)}
/> />

View File

@ -26,6 +26,7 @@ import DlgSubstituteCst from '@/dialogs/DlgSubstituteCst';
import DlgUploadRSForm from '@/dialogs/DlgUploadRSForm'; import DlgUploadRSForm from '@/dialogs/DlgUploadRSForm';
import { import {
AccessPolicy, AccessPolicy,
ILibraryItemEditor,
ILibraryUpdateData, ILibraryUpdateData,
IVersionData, IVersionData,
LibraryItemID, LibraryItemID,
@ -55,13 +56,14 @@ import { promptUnsaved } from '@/utils/utils';
import { RSTabID } from './RSTabs'; import { RSTabID } from './RSTabs';
export interface IRSEditContext { export interface IRSEditContext extends ILibraryItemEditor {
schema?: IRSForm; schema?: IRSForm;
selected: ConstituentaID[]; selected: ConstituentaID[];
isMutable: boolean; isMutable: boolean;
isContentEditable: boolean; isContentEditable: boolean;
isProcessing: boolean; isProcessing: boolean;
isAttachedToOSS: boolean;
canProduceStructure: boolean; canProduceStructure: boolean;
nothingSelected: boolean; nothingSelected: boolean;
canDeleteSelected: boolean; canDeleteSelected: boolean;
@ -153,6 +155,13 @@ export const RSEditState = ({
() => !nothingSelected && selected.every(id => !model.schema?.cstByID.get(id)?.is_inherited), () => !nothingSelected && selected.every(id => !model.schema?.cstByID.get(id)?.is_inherited),
[selected, nothingSelected, model.schema] [selected, nothingSelected, model.schema]
); );
const isAttachedToOSS = useMemo(
() =>
!!model.schema &&
model.schema.oss.length > 0 &&
(model.schema.stats.count_inherited > 0 || model.schema.items.length === 0),
[model.schema]
);
const [showUpload, setShowUpload] = useState(false); const [showUpload, setShowUpload] = useState(false);
const [showClone, setShowClone] = useState(false); const [showClone, setShowClone] = useState(false);
@ -378,7 +387,7 @@ export const RSEditState = ({
const oldCount = model.schema.items.length; const oldCount = model.schema.items.length;
model.inlineSynthesis(data, newSchema => { model.inlineSynthesis(data, newSchema => {
setSelected([]); setSelected([]);
toast.success(information.addedConstituents(newSchema['items'].length - oldCount)); toast.success(information.addedConstituents(newSchema.items.length - oldCount));
}); });
}, },
[model, setSelected] [model, setSelected]
@ -618,6 +627,7 @@ export const RSEditState = ({
isMutable, isMutable,
isContentEditable, isContentEditable,
isProcessing: model.processing, isProcessing: model.processing,
isAttachedToOSS,
canProduceStructure, canProduceStructure,
nothingSelected, nothingSelected,
canDeleteSelected, canDeleteSelected,

View File

@ -75,7 +75,7 @@ function RSTabs() {
setIsModified(false); setIsModified(false);
if (activeTab === RSTabID.CST_EDIT) { if (activeTab === RSTabID.CST_EDIT) {
const cstID = Number(cstQuery); const cstID = Number(cstQuery);
if (cstID && schema && schema.cstByID.has(cstID)) { if (cstID && schema?.cstByID.has(cstID)) {
setSelected([cstID]); setSelected([cstID]);
} else if (schema && schema?.items.length > 0) { } else if (schema && schema?.items.length > 0) {
setSelected([schema.items[0].id]); setSelected([schema.items[0].id]);
@ -257,10 +257,7 @@ function RSTabs() {
<TabList className={clsx('mx-auto w-fit', 'flex items-stretch', 'border-b-2 border-x-2 divide-x-2')}> <TabList className={clsx('mx-auto w-fit', 'flex items-stretch', 'border-b-2 border-x-2 divide-x-2')}>
<MenuRSTabs onDestroy={onDestroySchema} /> <MenuRSTabs onDestroy={onDestroySchema} />
<TabLabel <TabLabel label='Карточка' titleHtml={`${schema.title ?? ''}<br />Версия: ${labelVersion(schema)}`} />
label='Карточка'
titleHtml={`Название: <b>${schema.title ?? ''}</b><br />Версия: ${labelVersion(schema)}`}
/>
<TabLabel <TabLabel
label='Содержание' label='Содержание'
titleHtml={`Конституент: ${schema.stats?.count_all ?? 0}<br />Ошибок: ${schema.stats?.count_errors ?? 0}`} titleHtml={`Конституент: ${schema.stats?.count_all ?? 0}<br />Ошибок: ${schema.stats?.count_errors ?? 0}`}

View File

@ -2,8 +2,10 @@
import { useLayoutEffect, useMemo, useState } from 'react'; import { useLayoutEffect, useMemo, useState } from 'react';
import { IconChild } from '@/components/Icons';
import SelectGraphFilter from '@/components/select/SelectGraphFilter'; import SelectGraphFilter from '@/components/select/SelectGraphFilter';
import SelectMatchMode from '@/components/select/SelectMatchMode'; import SelectMatchMode from '@/components/select/SelectMatchMode';
import MiniButton from '@/components/ui/MiniButton';
import SearchBar from '@/components/ui/SearchBar'; import SearchBar from '@/components/ui/SearchBar';
import useLocalStorage from '@/hooks/useLocalStorage'; import useLocalStorage from '@/hooks/useLocalStorage';
import { CstMatchMode, DependencyMode } from '@/models/miscellaneous'; import { CstMatchMode, DependencyMode } from '@/models/miscellaneous';
@ -25,6 +27,7 @@ function ConstituentsSearch({ schema, activeID, activeExpression, dense, setFilt
const [filterMatch, setFilterMatch] = useLocalStorage(storage.cstFilterMatch, CstMatchMode.ALL); const [filterMatch, setFilterMatch] = useLocalStorage(storage.cstFilterMatch, CstMatchMode.ALL);
const [filterSource, setFilterSource] = useLocalStorage(storage.cstFilterGraph, DependencyMode.ALL); const [filterSource, setFilterSource] = useLocalStorage(storage.cstFilterGraph, DependencyMode.ALL);
const [filterText, setFilterText] = useState(''); const [filterText, setFilterText] = useState('');
const [showInherited, setShowInherited] = useLocalStorage(storage.cstFilterShowInherited, true);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!schema || schema.items.length === 0) { if (!schema || schema.items.length === 0) {
@ -48,8 +51,21 @@ function ConstituentsSearch({ schema, activeID, activeExpression, dense, setFilt
if (filterText) { if (filterText) {
result = result.filter(cst => matchConstituenta(cst, filterText, filterMatch)); result = result.filter(cst => matchConstituenta(cst, filterText, filterMatch));
} }
if (!showInherited) {
result = result.filter(cst => !cst.is_inherited);
}
setFiltered(result); setFiltered(result);
}, [filterText, setFiltered, filterSource, activeExpression, schema?.items, schema, filterMatch, activeID]); }, [
filterText,
setFiltered,
filterSource,
activeExpression,
schema?.items,
schema,
filterMatch,
activeID,
showInherited
]);
const selectGraph = useMemo( const selectGraph = useMemo(
() => <SelectGraphFilter value={filterSource} onChange={newValue => setFilterSource(newValue)} dense={dense} />, () => <SelectGraphFilter value={filterSource} onChange={newValue => setFilterSource(newValue)} dense={dense} />,
@ -72,6 +88,15 @@ function ConstituentsSearch({ schema, activeID, activeExpression, dense, setFilt
/> />
{selectMatchMode} {selectMatchMode}
{selectGraph} {selectGraph}
{schema && schema?.stats.count_inherited > 0 ? (
<MiniButton
noHover
titleHtml={`Наследованные: <b>${showInherited ? 'отображать' : 'скрывать'}</b>`}
icon={<IconChild size='1rem' className={showInherited ? 'icon-primary' : 'clr-text-controls'} />}
className='h-fit self-center'
onClick={() => setShowInherited(prev => !prev)}
/>
) : null}
</div> </div>
); );
} }

View File

@ -53,7 +53,7 @@ function TableSideConstituents({
useLayoutEffect(() => { useLayoutEffect(() => {
setColumnVisibility(prev => { setColumnVisibility(prev => {
const newValue = (windowSize.width ?? 0) >= denseThreshold; const newValue = (windowSize.width ?? 0) >= denseThreshold;
if (newValue === prev['expression']) { if (newValue === prev.expression) {
return prev; return prev;
} else { } else {
return { expression: newValue }; return { expression: newValue };

View File

@ -66,6 +66,8 @@
border: 1px solid; border: 1px solid;
padding: 2px; padding: 2px;
width: 150px; width: 150px;
height: 30px;
font-size: 14px;
border-radius: 5px; border-radius: 5px;
background-color: var(--cl-bg-120); background-color: var(--cl-bg-120);

View File

@ -37,11 +37,11 @@ function cursorNode({ type, from, to }: TreeCursor, isLeaf = false): CursorNode
return { type, from, to, isLeaf }; return { type, from, to, isLeaf };
} }
type TreeTraversalOptions = { interface TreeTraversalOptions {
beforeEnter?: (cursor: TreeCursor) => void; beforeEnter?: (cursor: TreeCursor) => void;
onEnter: (node: CursorNode) => false | void; onEnter: (node: CursorNode) => false | void;
onLeave?: (node: CursorNode) => false | void; onLeave?: (node: CursorNode) => false | void;
}; }
/** /**
* Implements depth-first traversal. * Implements depth-first traversal.

View File

@ -17,6 +17,8 @@ export const PARAMETER = {
ossContextMenuWidth: 200, // pixels - width of OSS context menu ossContextMenuWidth: 200, // pixels - width of OSS context menu
ossGridSize: 10, // pixels - size of OSS grid ossGridSize: 10, // pixels - size of OSS grid
ossMinDistance: 20, // pixels - minimum distance between node centers ossMinDistance: 20, // pixels - minimum distance between node centers
ossDistanceX: 180, // pixels - insert x-distance between node centers
ossDistanceY: 100, // pixels - insert y-distance between node centers
graphHoverXLimit: 0.4, // ratio to clientWidth used to determine which side of screen popup should be graphHoverXLimit: 0.4, // ratio to clientWidth used to determine which side of screen popup should be
graphHoverYLimit: 0.6, // ratio to clientHeight used to determine which side of screen popup should be graphHoverYLimit: 0.6, // ratio to clientHeight used to determine which side of screen popup should be
@ -121,7 +123,8 @@ export const storage = {
ossEdgeAnimate: 'oss.edge_animate', ossEdgeAnimate: 'oss.edge_animate',
cstFilterMatch: 'cst.filter.match', cstFilterMatch: 'cst.filter.match',
cstFilterGraph: 'cst.filter.graph' cstFilterGraph: 'cst.filter.graph',
cstFilterShowInherited: 'cst.filter.show_inherited'
}; };
/** /**

View File

@ -296,7 +296,7 @@ export function describeLocationHead(head: LocationHead): string {
/** /**
* Retrieves label for graph layout mode. * Retrieves label for graph layout mode.
*/ */
export const mapLabelLayout: Map<GraphLayout, string> = new Map([ export const mapLabelLayout = new Map<GraphLayout, string>([
['treeTd2d', 'Граф: ДеревоВ 2D'], ['treeTd2d', 'Граф: ДеревоВ 2D'],
['treeTd3d', 'Граф: ДеревоВ 3D'], ['treeTd3d', 'Граф: ДеревоВ 3D'],
['forceatlas2', 'Граф: Атлас 2D'], ['forceatlas2', 'Граф: Атлас 2D'],
@ -312,7 +312,7 @@ export const mapLabelLayout: Map<GraphLayout, string> = new Map([
/** /**
* Retrieves label for {@link GraphColoring}. * Retrieves label for {@link GraphColoring}.
*/ */
export const mapLabelColoring: Map<GraphColoring, string> = new Map([ export const mapLabelColoring = new Map<GraphColoring, string>([
['none', 'Цвет: Моно'], ['none', 'Цвет: Моно'],
['status', 'Цвет: Статус'], ['status', 'Цвет: Статус'],
['type', 'Цвет: Класс'] ['type', 'Цвет: Класс']
@ -321,7 +321,7 @@ export const mapLabelColoring: Map<GraphColoring, string> = new Map([
/** /**
* Retrieves label for {@link GraphSizing}. * Retrieves label for {@link GraphSizing}.
*/ */
export const mapLabelSizing: Map<GraphSizing, string> = new Map([ export const mapLabelSizing = new Map<GraphSizing, string>([
['none', 'Узлы: Моно'], ['none', 'Узлы: Моно'],
['derived', 'Узлы: Порожденные'], ['derived', 'Узлы: Порожденные'],
['complex', 'Узлы: Простые'] ['complex', 'Узлы: Простые']

View File

@ -29,14 +29,14 @@ export class TextMatcher {
} }
try { try {
this.query = new RegExp(query, isCaseSensitive ? '' : 'i'); this.query = new RegExp(query, isCaseSensitive ? '' : 'i');
} catch (exception: unknown) { } catch (_exception: unknown) {
this.query = query; this.query = query;
} }
} }
test(text: string): boolean { test(text: string): boolean {
if (typeof this.query === 'string') { if (typeof this.query === 'string') {
return text.indexOf(this.query) !== -1; return text.includes(this.query);
} else { } else {
return !!text.match(this.query); return !!text.match(this.query);
} }
@ -46,7 +46,7 @@ export class TextMatcher {
/** /**
* Text substitution guided by mapping and regular expression. * Text substitution guided by mapping and regular expression.
*/ */
export function applyPattern(text: string, mapping: { [key: string]: string }, pattern: RegExp): string { export function applyPattern(text: string, mapping: Record<string, string>, pattern: RegExp): string {
if (text === '' || pattern === null) { if (text === '' || pattern === null) {
return text; return text;
} }