Compare commits
38 Commits
8e474160d5
...
679eefcd38
Author | SHA1 | Date | |
---|---|---|---|
![]() |
679eefcd38 | ||
![]() |
94ea516256 | ||
![]() |
2cb7c44f3a | ||
![]() |
854d32aea1 | ||
![]() |
86d81d7652 | ||
![]() |
2a3f413315 | ||
![]() |
56f1bcacad | ||
![]() |
56652095af | ||
![]() |
12c202adff | ||
![]() |
2beed1c1c6 | ||
![]() |
c980ebab5a | ||
![]() |
1d11bd4ab5 | ||
![]() |
e4411c2c78 | ||
![]() |
1fda7c79c3 | ||
![]() |
fc32b2637c | ||
![]() |
a03c5d7fe8 | ||
![]() |
f61a8636f6 | ||
![]() |
3bca1a8921 | ||
![]() |
073cd5412f | ||
![]() |
4f47c736ce | ||
![]() |
7dc0088bd2 | ||
![]() |
09075f2416 | ||
![]() |
315c0f847e | ||
![]() |
d901094d8e | ||
![]() |
3feadb6f06 | ||
![]() |
d46fa536e6 | ||
![]() |
087ec8cf56 | ||
![]() |
68bde04dd1 | ||
![]() |
b62797205b | ||
![]() |
022041881b | ||
![]() |
cac508451d | ||
![]() |
9d8405fc36 | ||
![]() |
ae22e9b9f7 | ||
![]() |
d08d3432bc | ||
![]() |
1260f159c9 | ||
![]() |
c361047caf | ||
![]() |
161a51fc45 | ||
![]() |
cd62ad574f |
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from .basics import AccessPolicySerializer, LocationSerializer, RenameLocationSerializer
|
from .basics import AccessPolicySerializer, LocationSerializer, RenameLocationSerializer
|
||||||
from .data_access import (
|
from .data_access import (
|
||||||
|
LibraryItemBaseNonStrictSerializer,
|
||||||
LibraryItemBaseSerializer,
|
LibraryItemBaseSerializer,
|
||||||
LibraryItemCloneSerializer,
|
LibraryItemCloneSerializer,
|
||||||
LibraryItemDetailsSerializer,
|
LibraryItemDetailsSerializer,
|
||||||
|
|
|
@ -2,11 +2,12 @@
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from shared import messages as msg
|
from shared import messages as msg
|
||||||
|
from shared.serializers import StrictSerializer
|
||||||
|
|
||||||
from ..models import AccessPolicy, validate_location
|
from ..models import AccessPolicy, validate_location
|
||||||
|
|
||||||
|
|
||||||
class LocationSerializer(serializers.Serializer):
|
class LocationSerializer(StrictSerializer):
|
||||||
''' Serializer: Item location. '''
|
''' Serializer: Item location. '''
|
||||||
location = serializers.CharField(max_length=500)
|
location = serializers.CharField(max_length=500)
|
||||||
|
|
||||||
|
@ -19,7 +20,7 @@ class LocationSerializer(serializers.Serializer):
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class RenameLocationSerializer(serializers.Serializer):
|
class RenameLocationSerializer(StrictSerializer):
|
||||||
''' Serializer: rename location. '''
|
''' Serializer: rename location. '''
|
||||||
target = serializers.CharField(max_length=500)
|
target = serializers.CharField(max_length=500)
|
||||||
new_location = serializers.CharField(max_length=500)
|
new_location = serializers.CharField(max_length=500)
|
||||||
|
@ -37,7 +38,7 @@ class RenameLocationSerializer(serializers.Serializer):
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class AccessPolicySerializer(serializers.Serializer):
|
class AccessPolicySerializer(StrictSerializer):
|
||||||
''' Serializer: Constituenta renaming. '''
|
''' Serializer: Constituenta renaming. '''
|
||||||
access_policy = serializers.CharField()
|
access_policy = serializers.CharField()
|
||||||
|
|
||||||
|
|
|
@ -4,11 +4,13 @@ from rest_framework import serializers
|
||||||
from rest_framework.serializers import PrimaryKeyRelatedField as PKField
|
from rest_framework.serializers import PrimaryKeyRelatedField as PKField
|
||||||
|
|
||||||
from apps.rsform.models import Constituenta
|
from apps.rsform.models import Constituenta
|
||||||
|
from shared import messages
|
||||||
|
from shared.serializers import StrictModelSerializer, StrictSerializer
|
||||||
|
|
||||||
from ..models import LibraryItem, Version
|
from ..models import LibraryItem, Version
|
||||||
|
|
||||||
|
|
||||||
class LibraryItemBaseSerializer(serializers.ModelSerializer):
|
class LibraryItemBaseSerializer(StrictModelSerializer):
|
||||||
''' Serializer: LibraryItem entry full access. '''
|
''' Serializer: LibraryItem entry full access. '''
|
||||||
class Meta:
|
class Meta:
|
||||||
''' serializer metadata. '''
|
''' serializer metadata. '''
|
||||||
|
@ -17,7 +19,16 @@ class LibraryItemBaseSerializer(serializers.ModelSerializer):
|
||||||
read_only_fields = ('id',)
|
read_only_fields = ('id',)
|
||||||
|
|
||||||
|
|
||||||
class LibraryItemReferenceSerializer(serializers.ModelSerializer):
|
class LibraryItemBaseNonStrictSerializer(serializers.ModelSerializer):
|
||||||
|
''' Serializer: LibraryItem entry full access and no strict validation. '''
|
||||||
|
class Meta:
|
||||||
|
''' serializer metadata. '''
|
||||||
|
model = LibraryItem
|
||||||
|
fields = '__all__'
|
||||||
|
read_only_fields = ('id',)
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryItemReferenceSerializer(StrictModelSerializer):
|
||||||
''' Serializer: reference to LibraryItem. '''
|
''' Serializer: reference to LibraryItem. '''
|
||||||
class Meta:
|
class Meta:
|
||||||
''' serializer metadata. '''
|
''' serializer metadata. '''
|
||||||
|
@ -25,7 +36,7 @@ class LibraryItemReferenceSerializer(serializers.ModelSerializer):
|
||||||
fields = 'id', 'alias'
|
fields = 'id', 'alias'
|
||||||
|
|
||||||
|
|
||||||
class LibraryItemSerializer(serializers.ModelSerializer):
|
class LibraryItemSerializer(StrictModelSerializer):
|
||||||
''' Serializer: LibraryItem entry limited access. '''
|
''' Serializer: LibraryItem entry limited access. '''
|
||||||
class Meta:
|
class Meta:
|
||||||
''' serializer metadata. '''
|
''' serializer metadata. '''
|
||||||
|
@ -34,17 +45,27 @@ class LibraryItemSerializer(serializers.ModelSerializer):
|
||||||
read_only_fields = ('id', 'item_type', 'owner', 'location', 'access_policy')
|
read_only_fields = ('id', 'item_type', 'owner', 'location', 'access_policy')
|
||||||
|
|
||||||
|
|
||||||
class LibraryItemCloneSerializer(serializers.ModelSerializer):
|
class LibraryItemCloneSerializer(StrictSerializer):
|
||||||
''' Serializer: LibraryItem cloning. '''
|
''' Serializer: LibraryItem cloning. '''
|
||||||
items = PKField(many=True, required=False, queryset=Constituenta.objects.all().only('pk'))
|
class ItemCloneData(StrictModelSerializer):
|
||||||
|
''' Serialize: LibraryItem cloning data. '''
|
||||||
class Meta:
|
class Meta:
|
||||||
''' serializer metadata. '''
|
''' serializer metadata. '''
|
||||||
model = LibraryItem
|
model = LibraryItem
|
||||||
exclude = ['id', 'item_type', 'owner']
|
exclude = ['id', 'item_type', 'owner', 'read_only']
|
||||||
|
|
||||||
|
items = PKField(many=True, queryset=Constituenta.objects.all().only('pk', 'schema_id'))
|
||||||
|
item_data = ItemCloneData()
|
||||||
|
|
||||||
|
def validate_items(self, value):
|
||||||
|
schema = self.context.get('schema')
|
||||||
|
invalid = [item.pk for item in value if item.schema_id != schema.id]
|
||||||
|
if invalid:
|
||||||
|
raise serializers.ValidationError(messages.constituentsInvalid(invalid))
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class VersionSerializer(serializers.ModelSerializer):
|
class VersionSerializer(StrictModelSerializer):
|
||||||
''' Serializer: Version data. '''
|
''' Serializer: Version data. '''
|
||||||
class Meta:
|
class Meta:
|
||||||
''' serializer metadata. '''
|
''' serializer metadata. '''
|
||||||
|
@ -53,7 +74,7 @@ class VersionSerializer(serializers.ModelSerializer):
|
||||||
read_only_fields = ('id', 'item', 'time_create')
|
read_only_fields = ('id', 'item', 'time_create')
|
||||||
|
|
||||||
|
|
||||||
class VersionInnerSerializer(serializers.ModelSerializer):
|
class VersionInnerSerializer(StrictModelSerializer):
|
||||||
''' Serializer: Version data for list of versions. '''
|
''' Serializer: Version data for list of versions. '''
|
||||||
class Meta:
|
class Meta:
|
||||||
''' serializer metadata. '''
|
''' serializer metadata. '''
|
||||||
|
@ -62,7 +83,7 @@ class VersionInnerSerializer(serializers.ModelSerializer):
|
||||||
read_only_fields = ('id', 'item', 'time_create')
|
read_only_fields = ('id', 'item', 'time_create')
|
||||||
|
|
||||||
|
|
||||||
class VersionCreateSerializer(serializers.ModelSerializer):
|
class VersionCreateSerializer(StrictModelSerializer):
|
||||||
''' Serializer: Version create data. '''
|
''' Serializer: Version create data. '''
|
||||||
items = PKField(many=True, required=False, default=None, queryset=Constituenta.objects.all().only('pk'))
|
items = PKField(many=True, required=False, default=None, queryset=Constituenta.objects.all().only('pk'))
|
||||||
|
|
||||||
|
@ -72,7 +93,7 @@ class VersionCreateSerializer(serializers.ModelSerializer):
|
||||||
fields = 'version', 'description', 'items'
|
fields = 'version', 'description', 'items'
|
||||||
|
|
||||||
|
|
||||||
class LibraryItemDetailsSerializer(serializers.ModelSerializer):
|
class LibraryItemDetailsSerializer(StrictModelSerializer):
|
||||||
''' Serializer: LibraryItem detailed data. '''
|
''' Serializer: LibraryItem detailed data. '''
|
||||||
editors = serializers.SerializerMethodField()
|
editors = serializers.SerializerMethodField()
|
||||||
versions = serializers.SerializerMethodField()
|
versions = serializers.SerializerMethodField()
|
||||||
|
@ -90,11 +111,11 @@ class LibraryItemDetailsSerializer(serializers.ModelSerializer):
|
||||||
return [VersionInnerSerializer(item).data for item in instance.getQ_versions().order_by('pk')]
|
return [VersionInnerSerializer(item).data for item in instance.getQ_versions().order_by('pk')]
|
||||||
|
|
||||||
|
|
||||||
class UserTargetSerializer(serializers.Serializer):
|
class UserTargetSerializer(StrictSerializer):
|
||||||
''' Serializer: Target single User. '''
|
''' Serializer: Target single User. '''
|
||||||
user = PKField(many=False, queryset=User.objects.all().only('pk'))
|
user = PKField(many=False, queryset=User.objects.all().only('pk'))
|
||||||
|
|
||||||
|
|
||||||
class UsersListSerializer(serializers.Serializer):
|
class UsersListSerializer(StrictSerializer):
|
||||||
''' Serializer: List of Users. '''
|
''' Serializer: List of Users. '''
|
||||||
users = PKField(many=True, queryset=User.objects.all().only('pk'))
|
users = PKField(many=True, queryset=User.objects.all().only('pk'))
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
''' Utility serializers for REST API schema - SHOULD NOT BE ACCESSED DIRECTLY. '''
|
''' Utility serializers for REST API schema - SHOULD NOT BE ACCESSED DIRECTLY. '''
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from shared.serializers import StrictSerializer
|
||||||
|
|
||||||
class NewVersionResponse(serializers.Serializer):
|
|
||||||
|
class NewVersionResponse(StrictSerializer):
|
||||||
''' Serializer: Create version response. '''
|
''' Serializer: Create version response. '''
|
||||||
version = serializers.IntegerField()
|
version = serializers.IntegerField()
|
||||||
schema = serializers.JSONField()
|
schema = serializers.JSONField()
|
||||||
|
|
|
@ -345,13 +345,12 @@ class TestLibraryViewset(EndpointTester):
|
||||||
term_resolved='люди'
|
term_resolved='люди'
|
||||||
)
|
)
|
||||||
|
|
||||||
data = {'title': 'Title1337'}
|
data = {'item_data': {'title': 'Title1337'}, 'items': []}
|
||||||
self.executeNotFound(data=data, item=self.invalid_item)
|
self.executeNotFound(data=data, item=self.invalid_item)
|
||||||
self.executeCreated(data=data, item=self.unowned.pk)
|
self.executeCreated(data=data, item=self.unowned.pk)
|
||||||
|
|
||||||
data = {'title': 'Title1338'}
|
|
||||||
response = self.executeCreated(data=data, item=self.owned.pk)
|
response = self.executeCreated(data=data, item=self.owned.pk)
|
||||||
self.assertEqual(response.data['title'], data['title'])
|
self.assertEqual(response.data['title'], data['item_data']['title'])
|
||||||
self.assertEqual(len(response.data['items']), 2)
|
self.assertEqual(len(response.data['items']), 2)
|
||||||
self.assertEqual(response.data['items'][0]['alias'], x12.alias)
|
self.assertEqual(response.data['items'][0]['alias'], x12.alias)
|
||||||
self.assertEqual(response.data['items'][0]['term_raw'], x12.term_raw)
|
self.assertEqual(response.data['items'][0]['term_raw'], x12.term_raw)
|
||||||
|
@ -359,14 +358,14 @@ class TestLibraryViewset(EndpointTester):
|
||||||
self.assertEqual(response.data['items'][1]['term_raw'], d2.term_raw)
|
self.assertEqual(response.data['items'][1]['term_raw'], d2.term_raw)
|
||||||
self.assertEqual(response.data['items'][1]['term_resolved'], d2.term_resolved)
|
self.assertEqual(response.data['items'][1]['term_resolved'], d2.term_resolved)
|
||||||
|
|
||||||
data = {'title': 'Title1340', 'items': []}
|
data = {'item_data': {'title': 'Title1340'}, 'items': []}
|
||||||
response = self.executeCreated(data=data, item=self.owned.pk)
|
response = self.executeCreated(data=data, item=self.owned.pk)
|
||||||
self.assertEqual(response.data['title'], data['title'])
|
self.assertEqual(response.data['title'], data['item_data']['title'])
|
||||||
self.assertEqual(len(response.data['items']), 2)
|
self.assertEqual(len(response.data['items']), 2)
|
||||||
|
|
||||||
data = {'title': 'Title1341', 'items': [x12.pk]}
|
data = {'item_data': {'title': 'Title1341'}, 'items': [x12.pk]}
|
||||||
response = self.executeCreated(data=data, item=self.owned.pk)
|
response = self.executeCreated(data=data, item=self.owned.pk)
|
||||||
self.assertEqual(response.data['title'], data['title'])
|
self.assertEqual(response.data['title'], data['item_data']['title'])
|
||||||
self.assertEqual(len(response.data['items']), 1)
|
self.assertEqual(len(response.data['items']), 1)
|
||||||
self.assertEqual(response.data['items'][0]['alias'], x12.alias)
|
self.assertEqual(response.data['items'][0]['alias'], x12.alias)
|
||||||
self.assertEqual(response.data['items'][0]['term_raw'], x12.term_raw)
|
self.assertEqual(response.data['items'][0]['term_raw'], x12.term_raw)
|
||||||
|
|
|
@ -151,22 +151,24 @@ class LibraryViewSet(viewsets.ModelViewSet):
|
||||||
@action(detail=True, methods=['post'], url_path='clone')
|
@action(detail=True, methods=['post'], url_path='clone')
|
||||||
def clone(self, request: Request, pk) -> HttpResponse:
|
def clone(self, request: Request, pk) -> HttpResponse:
|
||||||
''' Endpoint: Create deep copy of library item. '''
|
''' Endpoint: Create deep copy of library item. '''
|
||||||
serializer = s.LibraryItemCloneSerializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
item = self._get_item()
|
item = self._get_item()
|
||||||
if item.item_type != m.LibraryItemType.RSFORM:
|
if item.item_type != m.LibraryItemType.RSFORM:
|
||||||
return Response(status=c.HTTP_400_BAD_REQUEST)
|
return Response(status=c.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
serializer = s.LibraryItemCloneSerializer(data=request.data, context={'schema': item})
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
data = serializer.validated_data['item_data']
|
||||||
clone = deepcopy(item)
|
clone = deepcopy(item)
|
||||||
clone.pk = None
|
clone.pk = None
|
||||||
clone.owner = cast(User, self.request.user)
|
clone.owner = cast(User, self.request.user)
|
||||||
clone.title = serializer.validated_data['title']
|
clone.title = data['title']
|
||||||
clone.alias = serializer.validated_data.get('alias', '')
|
clone.alias = data.get('alias', '')
|
||||||
clone.description = serializer.validated_data.get('description', '')
|
clone.description = data.get('description', '')
|
||||||
clone.visible = serializer.validated_data.get('visible', True)
|
clone.visible = data.get('visible', True)
|
||||||
clone.read_only = False
|
clone.read_only = False
|
||||||
clone.access_policy = serializer.validated_data.get('access_policy', m.AccessPolicy.PUBLIC)
|
clone.access_policy = data.get('access_policy', m.AccessPolicy.PUBLIC)
|
||||||
clone.location = serializer.validated_data.get('location', m.LocationHead.USER)
|
clone.location = data.get('location', m.LocationHead.USER)
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
clone.save()
|
clone.save()
|
||||||
|
|
|
@ -5,9 +5,11 @@ from .data_access import (
|
||||||
ArgumentSerializer,
|
ArgumentSerializer,
|
||||||
BlockSerializer,
|
BlockSerializer,
|
||||||
CreateBlockSerializer,
|
CreateBlockSerializer,
|
||||||
CreateOperationSerializer,
|
CreateSchemaSerializer,
|
||||||
|
CreateSynthesisSerializer,
|
||||||
DeleteBlockSerializer,
|
DeleteBlockSerializer,
|
||||||
DeleteOperationSerializer,
|
DeleteOperationSerializer,
|
||||||
|
ImportSchemaSerializer,
|
||||||
MoveItemsSerializer,
|
MoveItemsSerializer,
|
||||||
OperationSchemaSerializer,
|
OperationSchemaSerializer,
|
||||||
OperationSerializer,
|
OperationSerializer,
|
||||||
|
|
|
@ -1,9 +1,19 @@
|
||||||
''' Basic serializers that do not interact with database. '''
|
''' Basic serializers that do not interact with database. '''
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from shared.serializers import StrictSerializer
|
||||||
|
|
||||||
class NodeSerializer(serializers.Serializer):
|
|
||||||
''' Block position. '''
|
class PositionSerializer(StrictSerializer):
|
||||||
|
''' Serializer: Position data. '''
|
||||||
|
x = serializers.FloatField()
|
||||||
|
y = serializers.FloatField()
|
||||||
|
width = serializers.FloatField()
|
||||||
|
height = serializers.FloatField()
|
||||||
|
|
||||||
|
|
||||||
|
class NodeSerializer(StrictSerializer):
|
||||||
|
''' Oss node serializer. '''
|
||||||
nodeID = serializers.CharField()
|
nodeID = serializers.CharField()
|
||||||
x = serializers.FloatField()
|
x = serializers.FloatField()
|
||||||
y = serializers.FloatField()
|
y = serializers.FloatField()
|
||||||
|
@ -11,12 +21,12 @@ class NodeSerializer(serializers.Serializer):
|
||||||
height = serializers.FloatField()
|
height = serializers.FloatField()
|
||||||
|
|
||||||
|
|
||||||
class LayoutSerializer(serializers.Serializer):
|
class LayoutSerializer(StrictSerializer):
|
||||||
''' Serializer: Layout data. '''
|
''' Serializer: Layout data. '''
|
||||||
data = serializers.ListField(child=NodeSerializer()) # type: ignore
|
data = serializers.ListField(child=NodeSerializer()) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
class SubstitutionExSerializer(serializers.Serializer):
|
class SubstitutionExSerializer(StrictSerializer):
|
||||||
''' Serializer: Substitution extended data. '''
|
''' Serializer: Substitution extended data. '''
|
||||||
operation = serializers.IntegerField()
|
operation = serializers.IntegerField()
|
||||||
original = serializers.IntegerField()
|
original = serializers.IntegerField()
|
||||||
|
|
|
@ -11,13 +11,16 @@ from apps.library.serializers import LibraryItemDetailsSerializer
|
||||||
from apps.rsform.models import Constituenta
|
from apps.rsform.models import Constituenta
|
||||||
from apps.rsform.serializers import SubstitutionSerializerBase
|
from apps.rsform.serializers import SubstitutionSerializerBase
|
||||||
from shared import messages as msg
|
from shared import messages as msg
|
||||||
|
from shared.serializers import StrictModelSerializer, StrictSerializer
|
||||||
|
|
||||||
from ..models import Argument, Block, Inheritance, Operation, OperationSchema, OperationType
|
from ..models import Argument, Block, Inheritance, Operation, OperationSchema, OperationType
|
||||||
from .basics import NodeSerializer, SubstitutionExSerializer
|
from .basics import NodeSerializer, PositionSerializer, SubstitutionExSerializer
|
||||||
|
|
||||||
|
|
||||||
class OperationSerializer(serializers.ModelSerializer):
|
class OperationSerializer(StrictModelSerializer):
|
||||||
''' Serializer: Operation data. '''
|
''' Serializer: Operation data. '''
|
||||||
|
is_import = serializers.BooleanField(default=False, required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
''' serializer metadata. '''
|
''' serializer metadata. '''
|
||||||
model = Operation
|
model = Operation
|
||||||
|
@ -25,7 +28,7 @@ class OperationSerializer(serializers.ModelSerializer):
|
||||||
read_only_fields = ('id', 'oss')
|
read_only_fields = ('id', 'oss')
|
||||||
|
|
||||||
|
|
||||||
class BlockSerializer(serializers.ModelSerializer):
|
class BlockSerializer(StrictModelSerializer):
|
||||||
''' Serializer: Block data. '''
|
''' Serializer: Block data. '''
|
||||||
class Meta:
|
class Meta:
|
||||||
''' serializer metadata. '''
|
''' serializer metadata. '''
|
||||||
|
@ -34,7 +37,7 @@ class BlockSerializer(serializers.ModelSerializer):
|
||||||
read_only_fields = ('id', 'oss')
|
read_only_fields = ('id', 'oss')
|
||||||
|
|
||||||
|
|
||||||
class ArgumentSerializer(serializers.ModelSerializer):
|
class ArgumentSerializer(StrictModelSerializer):
|
||||||
''' Serializer: Operation data. '''
|
''' Serializer: Operation data. '''
|
||||||
class Meta:
|
class Meta:
|
||||||
''' serializer metadata. '''
|
''' serializer metadata. '''
|
||||||
|
@ -42,9 +45,9 @@ class ArgumentSerializer(serializers.ModelSerializer):
|
||||||
fields = ('operation', 'argument')
|
fields = ('operation', 'argument')
|
||||||
|
|
||||||
|
|
||||||
class CreateBlockSerializer(serializers.Serializer):
|
class CreateBlockSerializer(StrictSerializer):
|
||||||
''' Serializer: Block creation. '''
|
''' Serializer: Block creation. '''
|
||||||
class BlockCreateData(serializers.ModelSerializer):
|
class BlockCreateData(StrictModelSerializer):
|
||||||
''' Serializer: Block creation data. '''
|
''' Serializer: Block creation data. '''
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -56,10 +59,7 @@ class CreateBlockSerializer(serializers.Serializer):
|
||||||
child=NodeSerializer()
|
child=NodeSerializer()
|
||||||
)
|
)
|
||||||
item_data = BlockCreateData()
|
item_data = BlockCreateData()
|
||||||
width = serializers.FloatField()
|
position = PositionSerializer()
|
||||||
height = serializers.FloatField()
|
|
||||||
position_x = serializers.FloatField()
|
|
||||||
position_y = serializers.FloatField()
|
|
||||||
children_operations = PKField(many=True, queryset=Operation.objects.all().only('oss_id'))
|
children_operations = PKField(many=True, queryset=Operation.objects.all().only('oss_id'))
|
||||||
children_blocks = PKField(many=True, queryset=Block.objects.all().only('oss_id'))
|
children_blocks = PKField(many=True, queryset=Block.objects.all().only('oss_id'))
|
||||||
|
|
||||||
|
@ -93,9 +93,9 @@ class CreateBlockSerializer(serializers.Serializer):
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class UpdateBlockSerializer(serializers.Serializer):
|
class UpdateBlockSerializer(StrictSerializer):
|
||||||
''' Serializer: Block update. '''
|
''' Serializer: Block update. '''
|
||||||
class UpdateBlockData(serializers.ModelSerializer):
|
class UpdateBlockData(StrictModelSerializer):
|
||||||
''' Serializer: Block update data. '''
|
''' Serializer: Block update data. '''
|
||||||
class Meta:
|
class Meta:
|
||||||
''' serializer metadata. '''
|
''' serializer metadata. '''
|
||||||
|
@ -123,14 +123,14 @@ class UpdateBlockSerializer(serializers.Serializer):
|
||||||
raise serializers.ValidationError({
|
raise serializers.ValidationError({
|
||||||
'parent': msg.parentNotInOSS()
|
'parent': msg.parentNotInOSS()
|
||||||
})
|
})
|
||||||
if parent == attrs['target']:
|
if attrs['target'].pk in _collect_ancestors(parent):
|
||||||
raise serializers.ValidationError({
|
raise serializers.ValidationError({
|
||||||
'parent': msg.blockCyclicHierarchy()
|
'parent': msg.blockCyclicHierarchy()
|
||||||
})
|
})
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class DeleteBlockSerializer(serializers.Serializer):
|
class DeleteBlockSerializer(StrictSerializer):
|
||||||
''' Serializer: Delete block. '''
|
''' Serializer: Delete block. '''
|
||||||
layout = serializers.ListField(
|
layout = serializers.ListField(
|
||||||
child=NodeSerializer()
|
child=NodeSerializer()
|
||||||
|
@ -147,7 +147,7 @@ class DeleteBlockSerializer(serializers.Serializer):
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class MoveItemsSerializer(serializers.Serializer):
|
class MoveItemsSerializer(StrictSerializer):
|
||||||
''' Serializer: Move items to another parent. '''
|
''' Serializer: Move items to another parent. '''
|
||||||
layout = serializers.ListField(
|
layout = serializers.ListField(
|
||||||
child=NodeSerializer()
|
child=NodeSerializer()
|
||||||
|
@ -191,30 +191,21 @@ class MoveItemsSerializer(serializers.Serializer):
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class CreateOperationSerializer(serializers.Serializer):
|
class CreateOperationData(StrictModelSerializer):
|
||||||
''' Serializer: Operation creation. '''
|
|
||||||
class CreateOperationData(serializers.ModelSerializer):
|
|
||||||
''' Serializer: Operation creation data. '''
|
''' Serializer: Operation creation data. '''
|
||||||
alias = serializers.CharField()
|
alias = serializers.CharField()
|
||||||
operation_type = serializers.ChoiceField(OperationType.choices)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
''' serializer metadata. '''
|
''' serializer metadata. '''
|
||||||
model = Operation
|
model = Operation
|
||||||
fields = \
|
fields = 'alias', 'title', 'description', 'parent'
|
||||||
'alias', 'operation_type', 'title', \
|
|
||||||
'description', 'result', 'parent'
|
|
||||||
|
|
||||||
layout = serializers.ListField(
|
|
||||||
child=NodeSerializer()
|
class CreateSchemaSerializer(StrictSerializer):
|
||||||
)
|
''' Serializer: Schema creation for new operation. '''
|
||||||
|
layout = serializers.ListField(child=NodeSerializer())
|
||||||
item_data = CreateOperationData()
|
item_data = CreateOperationData()
|
||||||
width = serializers.FloatField()
|
position = PositionSerializer()
|
||||||
height = serializers.FloatField()
|
|
||||||
position_x = serializers.FloatField()
|
|
||||||
position_y = serializers.FloatField()
|
|
||||||
create_schema = serializers.BooleanField(default=False, required=False)
|
|
||||||
arguments = PKField(many=True, queryset=Operation.objects.all().only('pk'), required=False)
|
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
oss = cast(LibraryItem, self.context['oss'])
|
oss = cast(LibraryItem, self.context['oss'])
|
||||||
|
@ -223,20 +214,88 @@ class CreateOperationSerializer(serializers.Serializer):
|
||||||
raise serializers.ValidationError({
|
raise serializers.ValidationError({
|
||||||
'parent': msg.parentNotInOSS()
|
'parent': msg.parentNotInOSS()
|
||||||
})
|
})
|
||||||
|
|
||||||
if 'arguments' not in attrs:
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
class ImportSchemaSerializer(StrictSerializer):
|
||||||
|
''' Serializer: Import schema to new operation. '''
|
||||||
|
layout = serializers.ListField(child=NodeSerializer())
|
||||||
|
item_data = CreateOperationData()
|
||||||
|
position = PositionSerializer()
|
||||||
|
|
||||||
|
source = PKField(
|
||||||
|
many=False,
|
||||||
|
queryset=LibraryItem.objects.filter(item_type=LibraryItemType.RSFORM)
|
||||||
|
) # type: ignore
|
||||||
|
clone_source = serializers.BooleanField()
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
oss = cast(LibraryItem, self.context['oss'])
|
||||||
|
parent = attrs['item_data'].get('parent')
|
||||||
|
if parent is not None and parent.oss_id != oss.pk:
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
'parent': msg.parentNotInOSS()
|
||||||
|
})
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
class CreateSynthesisSerializer(StrictSerializer):
|
||||||
|
''' Serializer: Synthesis operation creation. '''
|
||||||
|
layout = serializers.ListField(child=NodeSerializer())
|
||||||
|
item_data = CreateOperationData()
|
||||||
|
position = PositionSerializer()
|
||||||
|
|
||||||
|
arguments = PKField(
|
||||||
|
many=True,
|
||||||
|
queryset=Operation.objects.all().only('pk')
|
||||||
|
)
|
||||||
|
substitutions = serializers.ListField(
|
||||||
|
child=SubstitutionSerializerBase(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
oss = cast(LibraryItem, self.context['oss'])
|
||||||
|
parent = attrs['item_data'].get('parent')
|
||||||
|
if parent is not None and parent.oss_id != oss.pk:
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
'parent': msg.parentNotInOSS()
|
||||||
|
})
|
||||||
for operation in attrs['arguments']:
|
for operation in attrs['arguments']:
|
||||||
if operation.oss_id != oss.pk:
|
if operation.oss_id != oss.pk:
|
||||||
raise serializers.ValidationError({
|
raise serializers.ValidationError({
|
||||||
'arguments': msg.operationNotInOSS()
|
'arguments': msg.operationNotInOSS()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
schemas = [arg.result_id for arg in attrs['arguments'] if arg.result is not None]
|
||||||
|
substitutions = attrs['substitutions']
|
||||||
|
to_delete = {x['original'].pk for x in substitutions}
|
||||||
|
deleted = set()
|
||||||
|
for item in substitutions:
|
||||||
|
original_cst = cast(Constituenta, item['original'])
|
||||||
|
substitution_cst = cast(Constituenta, item['substitution'])
|
||||||
|
if original_cst.schema_id not in schemas:
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
f'{original_cst.pk}': msg.constituentaNotFromOperation()
|
||||||
|
})
|
||||||
|
if substitution_cst.schema_id not in schemas:
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
f'{substitution_cst.pk}': msg.constituentaNotFromOperation()
|
||||||
|
})
|
||||||
|
if original_cst.pk in deleted or substitution_cst.pk in to_delete:
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
f'{original_cst.pk}': msg.substituteDouble(original_cst.alias)
|
||||||
|
})
|
||||||
|
if original_cst.schema_id == substitution_cst.schema_id:
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
'alias': msg.substituteTrivial(original_cst.alias)
|
||||||
|
})
|
||||||
|
deleted.add(original_cst.pk)
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class UpdateOperationSerializer(serializers.Serializer):
|
class UpdateOperationSerializer(StrictSerializer):
|
||||||
''' Serializer: Operation update. '''
|
''' Serializer: Operation update. '''
|
||||||
class UpdateOperationData(serializers.ModelSerializer):
|
class UpdateOperationData(StrictModelSerializer):
|
||||||
''' Serializer: Operation update data. '''
|
''' Serializer: Operation update data. '''
|
||||||
class Meta:
|
class Meta:
|
||||||
''' serializer metadata. '''
|
''' serializer metadata. '''
|
||||||
|
@ -311,7 +370,7 @@ class UpdateOperationSerializer(serializers.Serializer):
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class DeleteOperationSerializer(serializers.Serializer):
|
class DeleteOperationSerializer(StrictSerializer):
|
||||||
''' Serializer: Delete operation. '''
|
''' Serializer: Delete operation. '''
|
||||||
layout = serializers.ListField(
|
layout = serializers.ListField(
|
||||||
child=NodeSerializer()
|
child=NodeSerializer()
|
||||||
|
@ -330,7 +389,7 @@ class DeleteOperationSerializer(serializers.Serializer):
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class TargetOperationSerializer(serializers.Serializer):
|
class TargetOperationSerializer(StrictSerializer):
|
||||||
''' Serializer: Target single operation. '''
|
''' Serializer: Target single operation. '''
|
||||||
layout = serializers.ListField(
|
layout = serializers.ListField(
|
||||||
child=NodeSerializer()
|
child=NodeSerializer()
|
||||||
|
@ -347,7 +406,7 @@ class TargetOperationSerializer(serializers.Serializer):
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class SetOperationInputSerializer(serializers.Serializer):
|
class SetOperationInputSerializer(StrictSerializer):
|
||||||
''' Serializer: Set input schema for operation. '''
|
''' Serializer: Set input schema for operation. '''
|
||||||
layout = serializers.ListField(
|
layout = serializers.ListField(
|
||||||
child=NodeSerializer()
|
child=NodeSerializer()
|
||||||
|
@ -374,7 +433,7 @@ class SetOperationInputSerializer(serializers.Serializer):
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class OperationSchemaSerializer(serializers.ModelSerializer):
|
class OperationSchemaSerializer(StrictModelSerializer):
|
||||||
''' Serializer: Detailed data for OSS. '''
|
''' Serializer: Detailed data for OSS. '''
|
||||||
operations = serializers.ListField(
|
operations = serializers.ListField(
|
||||||
child=OperationSerializer()
|
child=OperationSerializer()
|
||||||
|
@ -407,7 +466,13 @@ class OperationSchemaSerializer(serializers.ModelSerializer):
|
||||||
result['arguments'] = []
|
result['arguments'] = []
|
||||||
result['substitutions'] = []
|
result['substitutions'] = []
|
||||||
for operation in oss.operations().order_by('pk'):
|
for operation in oss.operations().order_by('pk'):
|
||||||
result['operations'].append(OperationSerializer(operation).data)
|
operation_data = OperationSerializer(operation).data
|
||||||
|
operation_result = operation.result
|
||||||
|
operation_data['is_import'] = \
|
||||||
|
operation_result is not None and \
|
||||||
|
(operation_result.owner_id != instance.owner_id or
|
||||||
|
operation_result.location != instance.location)
|
||||||
|
result['operations'].append(operation_data)
|
||||||
for block in oss.blocks().order_by('pk'):
|
for block in oss.blocks().order_by('pk'):
|
||||||
result['blocks'].append(BlockSerializer(block).data)
|
result['blocks'].append(BlockSerializer(block).data)
|
||||||
for argument in oss.arguments().order_by('order'):
|
for argument in oss.arguments().order_by('order'):
|
||||||
|
@ -425,7 +490,7 @@ class OperationSchemaSerializer(serializers.ModelSerializer):
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
class RelocateConstituentsSerializer(serializers.Serializer):
|
class RelocateConstituentsSerializer(StrictSerializer):
|
||||||
''' Serializer: Relocate constituents. '''
|
''' Serializer: Relocate constituents. '''
|
||||||
destination = PKField(
|
destination = PKField(
|
||||||
many=False,
|
many=False,
|
||||||
|
|
|
@ -2,29 +2,30 @@
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from apps.library.serializers import LibraryItemSerializer
|
from apps.library.serializers import LibraryItemSerializer
|
||||||
|
from shared.serializers import StrictSerializer
|
||||||
|
|
||||||
from .data_access import BlockSerializer, OperationSchemaSerializer, OperationSerializer
|
from .data_access import OperationSchemaSerializer
|
||||||
|
|
||||||
|
|
||||||
class OperationCreatedResponse(serializers.Serializer):
|
class OperationCreatedResponse(StrictSerializer):
|
||||||
''' Serializer: Create operation response. '''
|
''' Serializer: Create operation response. '''
|
||||||
new_operation = OperationSerializer()
|
new_operation = serializers.IntegerField()
|
||||||
oss = OperationSchemaSerializer()
|
oss = OperationSchemaSerializer()
|
||||||
|
|
||||||
|
|
||||||
class BlockCreatedResponse(serializers.Serializer):
|
class BlockCreatedResponse(StrictSerializer):
|
||||||
''' Serializer: Create block response. '''
|
''' Serializer: Create block response. '''
|
||||||
new_block = BlockSerializer()
|
new_block = serializers.IntegerField()
|
||||||
oss = OperationSchemaSerializer()
|
oss = OperationSchemaSerializer()
|
||||||
|
|
||||||
|
|
||||||
class SchemaCreatedResponse(serializers.Serializer):
|
class SchemaCreatedResponse(StrictSerializer):
|
||||||
''' Serializer: Create RSForm for input operation response. '''
|
''' Serializer: Create RSForm for input operation response. '''
|
||||||
new_schema = LibraryItemSerializer()
|
new_schema = LibraryItemSerializer()
|
||||||
oss = OperationSchemaSerializer()
|
oss = OperationSchemaSerializer()
|
||||||
|
|
||||||
|
|
||||||
class ConstituentaReferenceResponse(serializers.Serializer):
|
class ConstituentaReferenceResponse(StrictSerializer):
|
||||||
''' Serializer: Constituenta reference. '''
|
''' Serializer: Constituenta reference. '''
|
||||||
id = serializers.IntegerField()
|
id = serializers.IntegerField()
|
||||||
schema = serializers.IntegerField()
|
schema = serializers.IntegerField()
|
||||||
|
|
|
@ -112,18 +112,6 @@ class TestChangeConstituents(EndpointTester):
|
||||||
self.assertEqual(inherited_cst.definition_formal, 'X1 = X2')
|
self.assertEqual(inherited_cst.definition_formal, 'X1 = X2')
|
||||||
|
|
||||||
|
|
||||||
@decl_endpoint('/api/rsforms/{schema}/rename-cst', method='patch')
|
|
||||||
def test_rename_constituenta(self):
|
|
||||||
data = {'target': self.ks1X1.pk, 'alias': 'D21', 'cst_type': CstType.TERM}
|
|
||||||
response = self.executeOK(data=data, schema=self.ks1.model.pk)
|
|
||||||
self.ks1X1.refresh_from_db()
|
|
||||||
inherited_cst = Constituenta.objects.get(as_child__parent_id=self.ks1X1.pk)
|
|
||||||
self.assertEqual(self.ks1X1.alias, data['alias'])
|
|
||||||
self.assertEqual(self.ks1X1.cst_type, data['cst_type'])
|
|
||||||
self.assertEqual(inherited_cst.alias, 'D2')
|
|
||||||
self.assertEqual(inherited_cst.cst_type, data['cst_type'])
|
|
||||||
|
|
||||||
|
|
||||||
@decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch')
|
@decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch')
|
||||||
def test_update_constituenta(self):
|
def test_update_constituenta(self):
|
||||||
d2 = self.ks3.insert_new('D2', cst_type=CstType.TERM, definition_raw='@{X1|sing,nomn}')
|
d2 = self.ks3.insert_new('D2', cst_type=CstType.TERM, definition_raw='@{X1|sing,nomn}')
|
||||||
|
|
|
@ -73,10 +73,12 @@ class TestOssBlocks(EndpointTester):
|
||||||
'description': 'Тест кириллицы',
|
'description': 'Тест кириллицы',
|
||||||
},
|
},
|
||||||
'layout': self.layout_data,
|
'layout': self.layout_data,
|
||||||
'position_x': 1337,
|
'position': {
|
||||||
'position_y': 1337,
|
'x': 1337,
|
||||||
|
'y': 1337,
|
||||||
'width': 0.42,
|
'width': 0.42,
|
||||||
'height': 0.42,
|
'height': 0.42
|
||||||
|
},
|
||||||
'children_operations': [],
|
'children_operations': [],
|
||||||
'children_blocks': []
|
'children_blocks': []
|
||||||
}
|
}
|
||||||
|
@ -86,14 +88,11 @@ class TestOssBlocks(EndpointTester):
|
||||||
self.assertEqual(len(response.data['oss']['blocks']), 3)
|
self.assertEqual(len(response.data['oss']['blocks']), 3)
|
||||||
new_block = response.data['new_block']
|
new_block = response.data['new_block']
|
||||||
layout = response.data['oss']['layout']
|
layout = response.data['oss']['layout']
|
||||||
item = [item for item in layout if item['nodeID'] == 'b' + str(new_block['id'])][0]
|
block_node = [item for item in layout if item['nodeID'] == 'b' + str(new_block)][0]
|
||||||
self.assertEqual(new_block['title'], data['item_data']['title'])
|
self.assertEqual(block_node['x'], data['position']['x'])
|
||||||
self.assertEqual(new_block['description'], data['item_data']['description'])
|
self.assertEqual(block_node['y'], data['position']['y'])
|
||||||
self.assertEqual(new_block['parent'], None)
|
self.assertEqual(block_node['width'], data['position']['width'])
|
||||||
self.assertEqual(item['x'], data['position_x'])
|
self.assertEqual(block_node['height'], data['position']['height'])
|
||||||
self.assertEqual(item['y'], data['position_y'])
|
|
||||||
self.assertEqual(item['width'], data['width'])
|
|
||||||
self.assertEqual(item['height'], data['height'])
|
|
||||||
self.operation1.refresh_from_db()
|
self.operation1.refresh_from_db()
|
||||||
|
|
||||||
self.executeForbidden(data=data, item=self.unowned_id)
|
self.executeForbidden(data=data, item=self.unowned_id)
|
||||||
|
@ -111,10 +110,12 @@ class TestOssBlocks(EndpointTester):
|
||||||
'parent': self.invalid_id
|
'parent': self.invalid_id
|
||||||
},
|
},
|
||||||
'layout': self.layout_data,
|
'layout': self.layout_data,
|
||||||
'position_x': 1337,
|
'position': {
|
||||||
'position_y': 1337,
|
'x': 1337,
|
||||||
|
'y': 1337,
|
||||||
'width': 0.42,
|
'width': 0.42,
|
||||||
'height': 0.42,
|
'height': 0.42
|
||||||
|
},
|
||||||
'children_operations': [],
|
'children_operations': [],
|
||||||
'children_blocks': []
|
'children_blocks': []
|
||||||
}
|
}
|
||||||
|
@ -126,7 +127,8 @@ class TestOssBlocks(EndpointTester):
|
||||||
data['item_data']['parent'] = self.block1.pk
|
data['item_data']['parent'] = self.block1.pk
|
||||||
response = self.executeCreated(data=data)
|
response = self.executeCreated(data=data)
|
||||||
new_block = response.data['new_block']
|
new_block = response.data['new_block']
|
||||||
self.assertEqual(new_block['parent'], self.block1.pk)
|
block_data = next((block for block in response.data['oss']['blocks'] if block['id'] == new_block), None)
|
||||||
|
self.assertEqual(block_data['parent'], self.block1.pk)
|
||||||
|
|
||||||
|
|
||||||
@decl_endpoint('/api/oss/{item}/create-block', method='post')
|
@decl_endpoint('/api/oss/{item}/create-block', method='post')
|
||||||
|
@ -138,10 +140,12 @@ class TestOssBlocks(EndpointTester):
|
||||||
'description': 'Тест кириллицы',
|
'description': 'Тест кириллицы',
|
||||||
},
|
},
|
||||||
'layout': self.layout_data,
|
'layout': self.layout_data,
|
||||||
'position_x': 1337,
|
'position': {
|
||||||
'position_y': 1337,
|
'x': 1337,
|
||||||
|
'y': 1337,
|
||||||
'width': 0.42,
|
'width': 0.42,
|
||||||
'height': 0.42,
|
'height': 0.42
|
||||||
|
},
|
||||||
'children_operations': [self.invalid_id],
|
'children_operations': [self.invalid_id],
|
||||||
'children_blocks': []
|
'children_blocks': []
|
||||||
}
|
}
|
||||||
|
@ -162,8 +166,8 @@ class TestOssBlocks(EndpointTester):
|
||||||
new_block = response.data['new_block']
|
new_block = response.data['new_block']
|
||||||
self.operation1.refresh_from_db()
|
self.operation1.refresh_from_db()
|
||||||
self.block1.refresh_from_db()
|
self.block1.refresh_from_db()
|
||||||
self.assertEqual(self.operation1.parent.pk, new_block['id'])
|
self.assertEqual(self.operation1.parent.pk, new_block)
|
||||||
self.assertEqual(self.block1.parent.pk, new_block['id'])
|
self.assertEqual(self.block1.parent.pk, new_block)
|
||||||
|
|
||||||
|
|
||||||
@decl_endpoint('/api/oss/{item}/create-block', method='post')
|
@decl_endpoint('/api/oss/{item}/create-block', method='post')
|
||||||
|
@ -176,10 +180,12 @@ class TestOssBlocks(EndpointTester):
|
||||||
'parent': self.block2.pk
|
'parent': self.block2.pk
|
||||||
},
|
},
|
||||||
'layout': self.layout_data,
|
'layout': self.layout_data,
|
||||||
'position_x': 1337,
|
'position': {
|
||||||
'position_y': 1337,
|
'x': 1337,
|
||||||
|
'y': 1337,
|
||||||
'width': 0.42,
|
'width': 0.42,
|
||||||
'height': 0.42,
|
'height': 0.42
|
||||||
|
},
|
||||||
'children_operations': [],
|
'children_operations': [],
|
||||||
'children_blocks': [self.block1.pk]
|
'children_blocks': [self.block1.pk]
|
||||||
}
|
}
|
||||||
|
@ -260,3 +266,36 @@ class TestOssBlocks(EndpointTester):
|
||||||
|
|
||||||
data['layout'] = self.layout_data
|
data['layout'] = self.layout_data
|
||||||
self.executeOK(data=data)
|
self.executeOK(data=data)
|
||||||
|
|
||||||
|
|
||||||
|
@decl_endpoint('/api/oss/{item}/update-block', method='patch')
|
||||||
|
def test_update_block_cyclic_parent(self):
|
||||||
|
self.populateData()
|
||||||
|
# block1 -> block2
|
||||||
|
# Try to set block1's parent to block2 (should fail, direct cycle)
|
||||||
|
data = {
|
||||||
|
'target': self.block1.pk,
|
||||||
|
'item_data': {
|
||||||
|
'title': self.block1.title,
|
||||||
|
'description': self.block1.description,
|
||||||
|
'parent': self.block2.pk
|
||||||
|
},
|
||||||
|
}
|
||||||
|
self.executeBadData(data=data, item=self.owned_id)
|
||||||
|
|
||||||
|
# Create a deeper hierarchy: block1 -> block2 -> block3
|
||||||
|
self.block3 = self.owned.create_block(title='3', parent=self.block2)
|
||||||
|
# Try to set block1's parent to block3 (should fail, indirect cycle)
|
||||||
|
data['item_data']['parent'] = self.block3.pk
|
||||||
|
self.executeBadData(data=data, item=self.owned_id)
|
||||||
|
|
||||||
|
# Setting block2's parent to block1 (valid, as block1 is not a descendant)
|
||||||
|
data = {
|
||||||
|
'target': self.block2.pk,
|
||||||
|
'item_data': {
|
||||||
|
'title': self.block2.title,
|
||||||
|
'description': self.block2.description,
|
||||||
|
'parent': self.block1.pk
|
||||||
|
},
|
||||||
|
}
|
||||||
|
self.executeOK(data=data, item=self.owned_id)
|
||||||
|
|
|
@ -70,9 +70,10 @@ class TestOssOperations(EndpointTester):
|
||||||
}])
|
}])
|
||||||
|
|
||||||
|
|
||||||
@decl_endpoint('/api/oss/{item}/create-operation', method='post')
|
@decl_endpoint('/api/oss/{item}/create-schema', method='post')
|
||||||
def test_create_operation(self):
|
def test_create_schema(self):
|
||||||
self.populateData()
|
self.populateData()
|
||||||
|
Editor.add(self.owned.model.pk, self.user2.pk)
|
||||||
self.executeBadData(item=self.owned_id)
|
self.executeBadData(item=self.owned_id)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
|
@ -80,47 +81,50 @@ class TestOssOperations(EndpointTester):
|
||||||
'alias': 'Test3',
|
'alias': 'Test3',
|
||||||
'title': 'Test title',
|
'title': 'Test title',
|
||||||
'description': 'Тест кириллицы',
|
'description': 'Тест кириллицы',
|
||||||
|
'parent': None
|
||||||
},
|
},
|
||||||
'layout': self.layout_data,
|
'layout': self.layout_data,
|
||||||
'position_x': 1,
|
'position': {
|
||||||
'position_y': 1,
|
'x': 1,
|
||||||
|
'y': 1,
|
||||||
'width': 500,
|
'width': 500,
|
||||||
'height': 50
|
'height': 50
|
||||||
|
|
||||||
}
|
}
|
||||||
self.executeBadData(data=data)
|
}
|
||||||
|
|
||||||
data['item_data']['operation_type'] = 'invalid'
|
|
||||||
self.executeBadData(data=data)
|
|
||||||
|
|
||||||
data['item_data']['operation_type'] = OperationType.INPUT
|
|
||||||
self.executeNotFound(data=data, item=self.invalid_id)
|
self.executeNotFound(data=data, item=self.invalid_id)
|
||||||
|
|
||||||
response = self.executeCreated(data=data, item=self.owned_id)
|
response = self.executeCreated(data=data, item=self.owned_id)
|
||||||
self.assertEqual(len(response.data['oss']['operations']), 4)
|
self.assertEqual(len(response.data['oss']['operations']), 4)
|
||||||
new_operation = response.data['new_operation']
|
new_operation_id = response.data['new_operation']
|
||||||
|
new_operation = next(op for op in response.data['oss']['operations'] if op['id'] == new_operation_id)
|
||||||
layout = response.data['oss']['layout']
|
layout = response.data['oss']['layout']
|
||||||
item = [item for item in layout if item['nodeID'] == 'o' + str(new_operation['id'])][0]
|
operation_node = [item for item in layout if item['nodeID'] == 'o' + str(new_operation_id)][0]
|
||||||
|
schema = LibraryItem.objects.get(pk=new_operation['result'])
|
||||||
self.assertEqual(new_operation['alias'], data['item_data']['alias'])
|
self.assertEqual(new_operation['alias'], data['item_data']['alias'])
|
||||||
self.assertEqual(new_operation['operation_type'], data['item_data']['operation_type'])
|
self.assertEqual(new_operation['operation_type'], OperationType.INPUT)
|
||||||
self.assertEqual(new_operation['title'], data['item_data']['title'])
|
self.assertEqual(new_operation['title'], data['item_data']['title'])
|
||||||
self.assertEqual(new_operation['description'], data['item_data']['description'])
|
self.assertEqual(new_operation['description'], data['item_data']['description'])
|
||||||
self.assertEqual(new_operation['result'], None)
|
|
||||||
self.assertEqual(new_operation['parent'], None)
|
self.assertEqual(new_operation['parent'], None)
|
||||||
self.assertEqual(item['x'], data['position_x'])
|
self.assertNotEqual(new_operation['result'], None)
|
||||||
self.assertEqual(item['y'], data['position_y'])
|
self.assertEqual(operation_node['x'], data['position']['x'])
|
||||||
self.assertEqual(item['width'], data['width'])
|
self.assertEqual(operation_node['y'], data['position']['y'])
|
||||||
self.assertEqual(item['height'], data['height'])
|
self.assertEqual(operation_node['width'], data['position']['width'])
|
||||||
self.operation1.refresh_from_db()
|
self.assertEqual(operation_node['height'], data['position']['height'])
|
||||||
|
self.assertEqual(schema.alias, data['item_data']['alias'])
|
||||||
|
self.assertEqual(schema.title, data['item_data']['title'])
|
||||||
|
self.assertEqual(schema.description, data['item_data']['description'])
|
||||||
|
self.assertEqual(schema.visible, False)
|
||||||
|
self.assertEqual(schema.access_policy, self.owned.model.access_policy)
|
||||||
|
self.assertEqual(schema.location, self.owned.model.location)
|
||||||
|
self.assertIn(self.user2, schema.getQ_editors())
|
||||||
|
|
||||||
self.executeForbidden(data=data, item=self.unowned_id)
|
self.executeForbidden(data=data, item=self.unowned_id)
|
||||||
self.toggle_admin(True)
|
self.toggle_admin(True)
|
||||||
self.executeCreated(data=data, item=self.unowned_id)
|
self.executeCreated(data=data, item=self.unowned_id)
|
||||||
|
|
||||||
|
|
||||||
@decl_endpoint('/api/oss/{item}/create-operation', method='post')
|
@decl_endpoint('/api/oss/{item}/create-schema', method='post')
|
||||||
def test_create_operation_parent(self):
|
def test_create_schema_parent(self):
|
||||||
self.populateData()
|
self.populateData()
|
||||||
data = {
|
data = {
|
||||||
'item_data': {
|
'item_data': {
|
||||||
|
@ -128,14 +132,15 @@ class TestOssOperations(EndpointTester):
|
||||||
'alias': 'Test3',
|
'alias': 'Test3',
|
||||||
'title': 'Test title',
|
'title': 'Test title',
|
||||||
'description': '',
|
'description': '',
|
||||||
'operation_type': OperationType.INPUT
|
|
||||||
|
|
||||||
},
|
},
|
||||||
'layout': self.layout_data,
|
'layout': self.layout_data,
|
||||||
'position_x': 1,
|
'position': {
|
||||||
'position_y': 1,
|
'x': 1,
|
||||||
|
'y': 1,
|
||||||
'width': 500,
|
'width': 500,
|
||||||
'height': 50
|
'height': 50
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
self.executeBadData(data=data, item=self.owned_id)
|
self.executeBadData(data=data, item=self.owned_id)
|
||||||
|
@ -147,90 +152,40 @@ class TestOssOperations(EndpointTester):
|
||||||
block_owned = self.owned.create_block(title='TestBlock2')
|
block_owned = self.owned.create_block(title='TestBlock2')
|
||||||
data['item_data']['parent'] = block_owned.id
|
data['item_data']['parent'] = block_owned.id
|
||||||
response = self.executeCreated(data=data, item=self.owned_id)
|
response = self.executeCreated(data=data, item=self.owned_id)
|
||||||
|
new_operation_id = response.data['new_operation']
|
||||||
|
new_operation = next(op for op in response.data['oss']['operations'] if op['id'] == new_operation_id)
|
||||||
self.assertEqual(len(response.data['oss']['operations']), 4)
|
self.assertEqual(len(response.data['oss']['operations']), 4)
|
||||||
new_operation = response.data['new_operation']
|
|
||||||
self.assertEqual(new_operation['parent'], block_owned.id)
|
self.assertEqual(new_operation['parent'], block_owned.id)
|
||||||
|
|
||||||
|
|
||||||
@decl_endpoint('/api/oss/{item}/create-operation', method='post')
|
@decl_endpoint('/api/oss/{item}/create-synthesis', method='post')
|
||||||
def test_create_operation_arguments(self):
|
def test_create_synthesis(self):
|
||||||
self.populateData()
|
self.populateData()
|
||||||
data = {
|
|
||||||
'item_data': {
|
|
||||||
'alias': 'Test4',
|
|
||||||
'operation_type': OperationType.SYNTHESIS
|
|
||||||
},
|
|
||||||
'layout': self.layout_data,
|
|
||||||
'position_x': 1,
|
|
||||||
'position_y': 1,
|
|
||||||
'width': 500,
|
|
||||||
'height': 50,
|
|
||||||
'arguments': [self.operation1.pk, self.operation3.pk]
|
|
||||||
}
|
|
||||||
response = self.executeCreated(data=data, item=self.owned_id)
|
|
||||||
self.owned.refresh_from_db()
|
|
||||||
new_operation = response.data['new_operation']
|
|
||||||
arguments = self.owned.arguments()
|
|
||||||
self.assertTrue(arguments.filter(operation__id=new_operation['id'], argument=self.operation1))
|
|
||||||
self.assertTrue(arguments.filter(operation__id=new_operation['id'], argument=self.operation3))
|
|
||||||
|
|
||||||
|
|
||||||
@decl_endpoint('/api/oss/{item}/create-operation', method='post')
|
|
||||||
def test_create_operation_result(self):
|
|
||||||
self.populateData()
|
|
||||||
|
|
||||||
self.operation1.result = None
|
|
||||||
self.operation1.save()
|
|
||||||
|
|
||||||
data = {
|
|
||||||
'item_data': {
|
|
||||||
'alias': 'Test4',
|
|
||||||
'operation_type': OperationType.INPUT,
|
|
||||||
'result': self.ks1.model.pk
|
|
||||||
},
|
|
||||||
'layout': self.layout_data,
|
|
||||||
'position_x': 1,
|
|
||||||
'position_y': 1,
|
|
||||||
'width': 500,
|
|
||||||
'height': 50
|
|
||||||
}
|
|
||||||
response = self.executeCreated(data=data, item=self.owned_id)
|
|
||||||
new_operation = response.data['new_operation']
|
|
||||||
self.assertEqual(new_operation['result'], self.ks1.model.pk)
|
|
||||||
|
|
||||||
|
|
||||||
@decl_endpoint('/api/oss/{item}/create-operation', method='post')
|
|
||||||
def test_create_operation_schema(self):
|
|
||||||
self.populateData()
|
|
||||||
Editor.add(self.owned.model.pk, self.user2.pk)
|
|
||||||
data = {
|
data = {
|
||||||
'item_data': {
|
'item_data': {
|
||||||
'alias': 'Test4',
|
'alias': 'Test4',
|
||||||
'title': 'Test title',
|
'title': 'Test title',
|
||||||
'description': 'Comment',
|
'description': '',
|
||||||
'operation_type': OperationType.INPUT,
|
'parent': None
|
||||||
'result': self.ks1.model.pk
|
|
||||||
},
|
},
|
||||||
'create_schema': True,
|
|
||||||
'layout': self.layout_data,
|
'layout': self.layout_data,
|
||||||
'position_x': 1,
|
'position': {
|
||||||
'position_y': 1,
|
'x': 1,
|
||||||
|
'y': 1,
|
||||||
'width': 500,
|
'width': 500,
|
||||||
'height': 50
|
'height': 50
|
||||||
|
},
|
||||||
|
'arguments': [self.operation1.pk, self.operation3.pk],
|
||||||
|
'substitutions': []
|
||||||
}
|
}
|
||||||
self.executeBadData(data=data, item=self.owned_id)
|
|
||||||
data['item_data']['result'] = None
|
|
||||||
response = self.executeCreated(data=data, item=self.owned_id)
|
response = self.executeCreated(data=data, item=self.owned_id)
|
||||||
self.owned.refresh_from_db()
|
self.owned.refresh_from_db()
|
||||||
new_operation = response.data['new_operation']
|
new_operation_id = response.data['new_operation']
|
||||||
schema = LibraryItem.objects.get(pk=new_operation['result'])
|
new_operation = next(op for op in response.data['oss']['operations'] if op['id'] == new_operation_id)
|
||||||
self.assertEqual(schema.alias, data['item_data']['alias'])
|
arguments = self.owned.arguments()
|
||||||
self.assertEqual(schema.title, data['item_data']['title'])
|
self.assertTrue(arguments.filter(operation__id=new_operation_id, argument=self.operation1))
|
||||||
self.assertEqual(schema.description, data['item_data']['description'])
|
self.assertTrue(arguments.filter(operation__id=new_operation_id, argument=self.operation3))
|
||||||
self.assertEqual(schema.visible, False)
|
self.assertNotEqual(new_operation['result'], None)
|
||||||
self.assertEqual(schema.access_policy, self.owned.model.access_policy)
|
|
||||||
self.assertEqual(schema.location, self.owned.model.location)
|
|
||||||
self.assertIn(self.user2, schema.getQ_editors())
|
|
||||||
|
|
||||||
|
|
||||||
@decl_endpoint('/api/oss/{item}/delete-operation', method='patch')
|
@decl_endpoint('/api/oss/{item}/delete-operation', method='patch')
|
||||||
|
@ -497,3 +452,141 @@ class TestOssOperations(EndpointTester):
|
||||||
self.assertEqual(len(items), 1)
|
self.assertEqual(len(items), 1)
|
||||||
self.assertEqual(items[0].alias, 'X1')
|
self.assertEqual(items[0].alias, 'X1')
|
||||||
self.assertEqual(items[0].term_resolved, self.ks2X1.term_resolved)
|
self.assertEqual(items[0].term_resolved, self.ks2X1.term_resolved)
|
||||||
|
|
||||||
|
@decl_endpoint('/api/oss/{item}/import-schema', method='post')
|
||||||
|
def test_import_schema(self):
|
||||||
|
self.populateData()
|
||||||
|
target_ks = RSForm.create(
|
||||||
|
alias='KS_Target',
|
||||||
|
title='Target',
|
||||||
|
owner=self.user
|
||||||
|
)
|
||||||
|
data = {
|
||||||
|
'item_data': {
|
||||||
|
'alias': 'ImportedAlias',
|
||||||
|
'title': 'Imported Title',
|
||||||
|
'description': 'Imported Description',
|
||||||
|
'parent': None
|
||||||
|
},
|
||||||
|
'layout': self.layout_data,
|
||||||
|
'position': {
|
||||||
|
'x': 10,
|
||||||
|
'y': 20,
|
||||||
|
'width': 300,
|
||||||
|
'height': 60
|
||||||
|
},
|
||||||
|
'source': target_ks.model.pk,
|
||||||
|
'clone_source': False
|
||||||
|
}
|
||||||
|
response = self.executeCreated(data=data, item=self.owned_id)
|
||||||
|
new_operation_id = response.data['new_operation']
|
||||||
|
new_operation = next(op for op in response.data['oss']['operations'] if op['id'] == new_operation_id)
|
||||||
|
layout = response.data['oss']['layout']
|
||||||
|
operation_node = [item for item in layout if item['nodeID'] == 'o' + str(new_operation_id)][0]
|
||||||
|
schema = LibraryItem.objects.get(pk=new_operation['result'])
|
||||||
|
self.assertEqual(new_operation['alias'], data['item_data']['alias'])
|
||||||
|
self.assertEqual(new_operation['title'], data['item_data']['title'])
|
||||||
|
self.assertEqual(new_operation['description'], data['item_data']['description'])
|
||||||
|
self.assertEqual(new_operation['operation_type'], OperationType.INPUT)
|
||||||
|
self.assertEqual(schema.pk, target_ks.model.pk) # Not a clone
|
||||||
|
self.assertEqual(operation_node['x'], data['position']['x'])
|
||||||
|
self.assertEqual(operation_node['y'], data['position']['y'])
|
||||||
|
self.assertEqual(operation_node['width'], data['position']['width'])
|
||||||
|
self.assertEqual(operation_node['height'], data['position']['height'])
|
||||||
|
self.assertEqual(schema.visible, target_ks.model.visible)
|
||||||
|
self.assertEqual(schema.access_policy, target_ks.model.access_policy)
|
||||||
|
self.assertEqual(schema.location, target_ks.model.location)
|
||||||
|
|
||||||
|
@decl_endpoint('/api/oss/{item}/import-schema', method='post')
|
||||||
|
def test_import_schema_clone(self):
|
||||||
|
self.populateData()
|
||||||
|
# Use ks2 as the source RSForm
|
||||||
|
data = {
|
||||||
|
'item_data': {
|
||||||
|
'alias': 'ClonedAlias',
|
||||||
|
'title': 'Cloned Title',
|
||||||
|
'description': 'Cloned Description',
|
||||||
|
'parent': None
|
||||||
|
},
|
||||||
|
'layout': self.layout_data,
|
||||||
|
'position': {
|
||||||
|
'x': 42,
|
||||||
|
'y': 1337,
|
||||||
|
'width': 400,
|
||||||
|
'height': 80
|
||||||
|
},
|
||||||
|
'source': self.ks2.model.pk,
|
||||||
|
'clone_source': True
|
||||||
|
}
|
||||||
|
response = self.executeCreated(data=data, item=self.owned_id)
|
||||||
|
new_operation_id = response.data['new_operation']
|
||||||
|
new_operation = next(op for op in response.data['oss']['operations'] if op['id'] == new_operation_id)
|
||||||
|
layout = response.data['oss']['layout']
|
||||||
|
operation_node = [item for item in layout if item['nodeID'] == 'o' + str(new_operation_id)][0]
|
||||||
|
schema = LibraryItem.objects.get(pk=new_operation['result'])
|
||||||
|
self.assertEqual(new_operation['alias'], data['item_data']['alias'])
|
||||||
|
self.assertEqual(new_operation['title'], data['item_data']['title'])
|
||||||
|
self.assertEqual(new_operation['description'], data['item_data']['description'])
|
||||||
|
self.assertEqual(new_operation['operation_type'], OperationType.INPUT)
|
||||||
|
self.assertNotEqual(schema.pk, self.ks2.model.pk) # Should be a clone
|
||||||
|
self.assertEqual(schema.alias, data['item_data']['alias'])
|
||||||
|
self.assertEqual(schema.title, data['item_data']['title'])
|
||||||
|
self.assertEqual(schema.description, data['item_data']['description'])
|
||||||
|
self.assertEqual(operation_node['x'], data['position']['x'])
|
||||||
|
self.assertEqual(operation_node['y'], data['position']['y'])
|
||||||
|
self.assertEqual(operation_node['width'], data['position']['width'])
|
||||||
|
self.assertEqual(operation_node['height'], data['position']['height'])
|
||||||
|
self.assertEqual(schema.visible, False)
|
||||||
|
self.assertEqual(schema.access_policy, self.owned.model.access_policy)
|
||||||
|
self.assertEqual(schema.location, self.owned.model.location)
|
||||||
|
|
||||||
|
@decl_endpoint('/api/oss/{item}/import-schema', method='post')
|
||||||
|
def test_import_schema_bad_data(self):
|
||||||
|
self.populateData()
|
||||||
|
# Missing source
|
||||||
|
data = {
|
||||||
|
'item_data': {
|
||||||
|
'alias': 'Bad',
|
||||||
|
'title': 'Bad',
|
||||||
|
'description': 'Bad',
|
||||||
|
'parent': None
|
||||||
|
},
|
||||||
|
'layout': self.layout_data,
|
||||||
|
'position': {
|
||||||
|
'x': 0, 'y': 0, 'width': 1, 'height': 1
|
||||||
|
},
|
||||||
|
# 'source' missing
|
||||||
|
'clone_source': False
|
||||||
|
}
|
||||||
|
self.executeBadData(data=data, item=self.owned_id)
|
||||||
|
# Invalid source
|
||||||
|
data['source'] = self.invalid_id
|
||||||
|
self.executeBadData(data=data, item=self.owned_id)
|
||||||
|
# Invalid OSS
|
||||||
|
data['source'] = self.ks1.model.pk
|
||||||
|
self.executeNotFound(data=data, item=self.invalid_id)
|
||||||
|
|
||||||
|
@decl_endpoint('/api/oss/{item}/import-schema', method='post')
|
||||||
|
def test_import_schema_permissions(self):
|
||||||
|
self.populateData()
|
||||||
|
data = {
|
||||||
|
'item_data': {
|
||||||
|
'alias': 'PermTest',
|
||||||
|
'title': 'PermTest',
|
||||||
|
'description': 'PermTest',
|
||||||
|
'parent': None
|
||||||
|
},
|
||||||
|
'layout': self.layout_data,
|
||||||
|
'position': {
|
||||||
|
'x': 5, 'y': 5, 'width': 10, 'height': 10
|
||||||
|
},
|
||||||
|
'source': self.ks1.model.pk,
|
||||||
|
'clone_source': False
|
||||||
|
}
|
||||||
|
# Not an editor
|
||||||
|
self.logout()
|
||||||
|
self.executeForbidden(data=data, item=self.owned_id)
|
||||||
|
# As admin
|
||||||
|
self.login()
|
||||||
|
self.toggle_admin(True)
|
||||||
|
self.executeCreated(data=data, item=self.owned_id)
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
''' Endpoints for OSS. '''
|
''' Endpoints for OSS. '''
|
||||||
|
from copy import deepcopy
|
||||||
from typing import Optional, cast
|
from typing import Optional, cast
|
||||||
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Q
|
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||||
from rest_framework import generics, serializers
|
from rest_framework import generics, serializers
|
||||||
|
@ -23,6 +23,28 @@ from .. import models as m
|
||||||
from .. import serializers as s
|
from .. import serializers as s
|
||||||
|
|
||||||
|
|
||||||
|
def _create_clone(prototype: LibraryItem, operation: m.Operation, oss: LibraryItem) -> LibraryItem:
|
||||||
|
''' Create clone of prototype schema for operation. '''
|
||||||
|
prototype_schema = RSForm(prototype)
|
||||||
|
clone = deepcopy(prototype)
|
||||||
|
clone.pk = None
|
||||||
|
clone.owner = oss.owner
|
||||||
|
clone.title = operation.title
|
||||||
|
clone.alias = operation.alias
|
||||||
|
clone.description = operation.description
|
||||||
|
clone.visible = False
|
||||||
|
clone.read_only = False
|
||||||
|
clone.access_policy = oss.access_policy
|
||||||
|
clone.location = oss.location
|
||||||
|
clone.save()
|
||||||
|
for cst in prototype_schema.constituents():
|
||||||
|
cst_copy = deepcopy(cst)
|
||||||
|
cst_copy.pk = None
|
||||||
|
cst_copy.schema = clone
|
||||||
|
cst_copy.save()
|
||||||
|
return clone
|
||||||
|
|
||||||
|
|
||||||
@extend_schema(tags=['OSS'])
|
@extend_schema(tags=['OSS'])
|
||||||
@extend_schema_view()
|
@extend_schema_view()
|
||||||
class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.RetrieveAPIView):
|
class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.RetrieveAPIView):
|
||||||
|
@ -41,7 +63,9 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
|
||||||
'update_block',
|
'update_block',
|
||||||
'delete_block',
|
'delete_block',
|
||||||
'move_items',
|
'move_items',
|
||||||
'create_operation',
|
'create_schema',
|
||||||
|
'import_schema',
|
||||||
|
'create_synthesis',
|
||||||
'update_operation',
|
'update_operation',
|
||||||
'delete_operation',
|
'delete_operation',
|
||||||
'create_input',
|
'create_input',
|
||||||
|
@ -116,16 +140,17 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
|
||||||
|
|
||||||
oss = m.OperationSchema(self.get_object())
|
oss = m.OperationSchema(self.get_object())
|
||||||
layout = serializer.validated_data['layout']
|
layout = serializer.validated_data['layout']
|
||||||
|
position = serializer.validated_data['position']
|
||||||
children_blocks: list[m.Block] = serializer.validated_data['children_blocks']
|
children_blocks: list[m.Block] = serializer.validated_data['children_blocks']
|
||||||
children_operations: list[m.Operation] = serializer.validated_data['children_operations']
|
children_operations: list[m.Operation] = serializer.validated_data['children_operations']
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
new_block = oss.create_block(**serializer.validated_data['item_data'])
|
new_block = oss.create_block(**serializer.validated_data['item_data'])
|
||||||
layout.append({
|
layout.append({
|
||||||
'nodeID': 'b' + str(new_block.pk),
|
'nodeID': 'b' + str(new_block.pk),
|
||||||
'x': serializer.validated_data['position_x'],
|
'x': position['x'],
|
||||||
'y': serializer.validated_data['position_y'],
|
'y': position['y'],
|
||||||
'width': serializer.validated_data['width'],
|
'width': position['width'],
|
||||||
'height': serializer.validated_data['height'],
|
'height': position['height'],
|
||||||
})
|
})
|
||||||
oss.update_layout(layout)
|
oss.update_layout(layout)
|
||||||
if len(children_blocks) > 0:
|
if len(children_blocks) > 0:
|
||||||
|
@ -140,7 +165,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
|
||||||
return Response(
|
return Response(
|
||||||
status=c.HTTP_201_CREATED,
|
status=c.HTTP_201_CREATED,
|
||||||
data={
|
data={
|
||||||
'new_block': s.BlockSerializer(new_block).data,
|
'new_block': new_block.pk,
|
||||||
'oss': s.OperationSchemaSerializer(oss.model).data
|
'oss': s.OperationSchemaSerializer(oss.model).data
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -251,9 +276,9 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
|
||||||
)
|
)
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
summary='create operation',
|
summary='create empty conceptual schema',
|
||||||
tags=['OSS'],
|
tags=['OSS'],
|
||||||
request=s.CreateOperationSerializer(),
|
request=s.CreateSchemaSerializer(),
|
||||||
responses={
|
responses={
|
||||||
c.HTTP_201_CREATED: s.OperationCreatedResponse,
|
c.HTTP_201_CREATED: s.OperationCreatedResponse,
|
||||||
c.HTTP_400_BAD_REQUEST: None,
|
c.HTTP_400_BAD_REQUEST: None,
|
||||||
|
@ -261,10 +286,10 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
|
||||||
c.HTTP_404_NOT_FOUND: None
|
c.HTTP_404_NOT_FOUND: None
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@action(detail=True, methods=['post'], url_path='create-operation')
|
@action(detail=True, methods=['post'], url_path='create-schema')
|
||||||
def create_operation(self, request: Request, pk) -> HttpResponse:
|
def create_schema(self, request: Request, pk) -> HttpResponse:
|
||||||
''' Create Operation. '''
|
''' Create schema. '''
|
||||||
serializer = s.CreateOperationSerializer(
|
serializer = s.CreateSchemaSerializer(
|
||||||
data=request.data,
|
data=request.data,
|
||||||
context={'oss': self.get_object()}
|
context={'oss': self.get_object()}
|
||||||
)
|
)
|
||||||
|
@ -272,43 +297,124 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
|
||||||
|
|
||||||
oss = m.OperationSchema(self.get_object())
|
oss = m.OperationSchema(self.get_object())
|
||||||
layout = serializer.validated_data['layout']
|
layout = serializer.validated_data['layout']
|
||||||
|
position = serializer.validated_data['position']
|
||||||
|
data = serializer.validated_data['item_data']
|
||||||
|
data['operation_type'] = m.OperationType.INPUT
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
new_operation = oss.create_operation(**serializer.validated_data['item_data'])
|
new_operation = oss.create_operation(**serializer.validated_data['item_data'])
|
||||||
layout.append({
|
layout.append({
|
||||||
'nodeID': 'o' + str(new_operation.pk),
|
'nodeID': 'o' + str(new_operation.pk),
|
||||||
'x': serializer.validated_data['position_x'],
|
'x': position['x'],
|
||||||
'y': serializer.validated_data['position_y'],
|
'y': position['y'],
|
||||||
'width': serializer.validated_data['width'],
|
'width': position['width'],
|
||||||
'height': serializer.validated_data['height']
|
'height': position['height']
|
||||||
})
|
})
|
||||||
oss.update_layout(layout)
|
oss.update_layout(layout)
|
||||||
|
|
||||||
schema = new_operation.result
|
|
||||||
if schema is not None:
|
|
||||||
connected_operations = \
|
|
||||||
m.Operation.objects \
|
|
||||||
.filter(Q(result=schema) & ~Q(pk=new_operation.pk)) \
|
|
||||||
.only('operation_type', 'oss_id')
|
|
||||||
for operation in connected_operations:
|
|
||||||
if operation.operation_type != m.OperationType.INPUT:
|
|
||||||
raise serializers.ValidationError({
|
|
||||||
'item_data': msg.operationResultFromAnotherOSS()
|
|
||||||
})
|
|
||||||
if operation.oss_id == new_operation.oss_id:
|
|
||||||
raise serializers.ValidationError({
|
|
||||||
'item_data': msg.operationInputAlreadyConnected()
|
|
||||||
})
|
|
||||||
if new_operation.operation_type == m.OperationType.INPUT and serializer.validated_data['create_schema']:
|
|
||||||
oss.create_input(new_operation)
|
oss.create_input(new_operation)
|
||||||
if new_operation.operation_type != m.OperationType.INPUT and 'arguments' in serializer.validated_data:
|
|
||||||
oss.set_arguments(
|
|
||||||
target=new_operation.pk,
|
|
||||||
arguments=serializer.validated_data['arguments']
|
|
||||||
)
|
|
||||||
return Response(
|
return Response(
|
||||||
status=c.HTTP_201_CREATED,
|
status=c.HTTP_201_CREATED,
|
||||||
data={
|
data={
|
||||||
'new_operation': s.OperationSerializer(new_operation).data,
|
'new_operation': new_operation.pk,
|
||||||
|
'oss': s.OperationSchemaSerializer(oss.model).data
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
summary='import conceptual schema to new OSS operation',
|
||||||
|
tags=['OSS'],
|
||||||
|
request=s.ImportSchemaSerializer(),
|
||||||
|
responses={
|
||||||
|
c.HTTP_201_CREATED: s.OperationCreatedResponse,
|
||||||
|
c.HTTP_400_BAD_REQUEST: None,
|
||||||
|
c.HTTP_403_FORBIDDEN: None,
|
||||||
|
c.HTTP_404_NOT_FOUND: None
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@action(detail=True, methods=['post'], url_path='import-schema')
|
||||||
|
def import_schema(self, request: Request, pk) -> HttpResponse:
|
||||||
|
''' Create operation with existing schema. '''
|
||||||
|
serializer = s.ImportSchemaSerializer(
|
||||||
|
data=request.data,
|
||||||
|
context={'oss': self.get_object()}
|
||||||
|
)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
oss = m.OperationSchema(self.get_object())
|
||||||
|
layout = serializer.validated_data['layout']
|
||||||
|
position = serializer.validated_data['position']
|
||||||
|
data = serializer.validated_data['item_data']
|
||||||
|
data['operation_type'] = m.OperationType.INPUT
|
||||||
|
if not serializer.validated_data['clone_source']:
|
||||||
|
data['result'] = serializer.validated_data['source']
|
||||||
|
with transaction.atomic():
|
||||||
|
new_operation = oss.create_operation(**serializer.validated_data['item_data'])
|
||||||
|
layout.append({
|
||||||
|
'nodeID': 'o' + str(new_operation.pk),
|
||||||
|
'x': position['x'],
|
||||||
|
'y': position['y'],
|
||||||
|
'width': position['width'],
|
||||||
|
'height': position['height']
|
||||||
|
})
|
||||||
|
oss.update_layout(layout)
|
||||||
|
|
||||||
|
if serializer.validated_data['clone_source']:
|
||||||
|
prototype: LibraryItem = serializer.validated_data['source']
|
||||||
|
new_operation.result = _create_clone(prototype, new_operation, oss.model)
|
||||||
|
new_operation.save(update_fields=["result"])
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
status=c.HTTP_201_CREATED,
|
||||||
|
data={
|
||||||
|
'new_operation': new_operation.pk,
|
||||||
|
'oss': s.OperationSchemaSerializer(oss.model).data
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
summary='create synthesis operation',
|
||||||
|
tags=['OSS'],
|
||||||
|
request=s.CreateSynthesisSerializer(),
|
||||||
|
responses={
|
||||||
|
c.HTTP_201_CREATED: s.OperationCreatedResponse,
|
||||||
|
c.HTTP_400_BAD_REQUEST: None,
|
||||||
|
c.HTTP_403_FORBIDDEN: None,
|
||||||
|
c.HTTP_404_NOT_FOUND: None
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@action(detail=True, methods=['post'], url_path='create-synthesis')
|
||||||
|
def create_synthesis(self, request: Request, pk) -> HttpResponse:
|
||||||
|
''' Create Synthesis operation from arguments. '''
|
||||||
|
serializer = s.CreateSynthesisSerializer(
|
||||||
|
data=request.data,
|
||||||
|
context={'oss': self.get_object()}
|
||||||
|
)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
oss = m.OperationSchema(self.get_object())
|
||||||
|
layout = serializer.validated_data['layout']
|
||||||
|
position = serializer.validated_data['position']
|
||||||
|
data = serializer.validated_data['item_data']
|
||||||
|
data['operation_type'] = m.OperationType.SYNTHESIS
|
||||||
|
with transaction.atomic():
|
||||||
|
new_operation = oss.create_operation(**serializer.validated_data['item_data'])
|
||||||
|
layout.append({
|
||||||
|
'nodeID': 'o' + str(new_operation.pk),
|
||||||
|
'x': position['x'],
|
||||||
|
'y': position['y'],
|
||||||
|
'width': position['width'],
|
||||||
|
'height': position['height']
|
||||||
|
})
|
||||||
|
oss.set_arguments(new_operation.pk, serializer.validated_data['arguments'])
|
||||||
|
oss.set_substitutions(new_operation.pk, serializer.validated_data['substitutions'])
|
||||||
|
oss.execute_operation(new_operation)
|
||||||
|
oss.update_layout(layout)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
status=c.HTTP_201_CREATED,
|
||||||
|
data={
|
||||||
|
'new_operation': new_operation.pk,
|
||||||
'oss': s.OperationSchemaSerializer(oss.model).data
|
'oss': s.OperationSchemaSerializer(oss.model).data
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
0
rsconcept/backend/apps/prompt/__init__.py
Normal file
0
rsconcept/backend/apps/prompt/__init__.py
Normal file
12
rsconcept/backend/apps/prompt/admin.py
Normal file
12
rsconcept/backend/apps/prompt/admin.py
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
''' Admin view: Prompts for AI helper. '''
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(models.PromptTemplate)
|
||||||
|
class PromptTemplateAdmin(admin.ModelAdmin):
|
||||||
|
''' Admin model: PromptTemplate. '''
|
||||||
|
list_display = ('id', 'label', 'owner', 'is_shared')
|
||||||
|
list_filter = ('is_shared', 'owner')
|
||||||
|
search_fields = ('label', 'description', 'text')
|
8
rsconcept/backend/apps/prompt/apps.py
Normal file
8
rsconcept/backend/apps/prompt/apps.py
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
''' Application: Prompts for AI helper. '''
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PromptConfig(AppConfig):
|
||||||
|
''' Application config. '''
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'apps.prompt'
|
28
rsconcept/backend/apps/prompt/migrations/0001_initial.py
Normal file
28
rsconcept/backend/apps/prompt/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
# Generated by Django 5.2.4 on 2025-07-13 13:11
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PromptTemplate',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('is_shared', models.BooleanField(default=False, verbose_name='Общий доступ')),
|
||||||
|
('label', models.CharField(max_length=255, verbose_name='Название')),
|
||||||
|
('description', models.TextField(blank=True, verbose_name='Описание')),
|
||||||
|
('text', models.TextField(blank=True, verbose_name='Содержание')),
|
||||||
|
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='prompt_templates', to=settings.AUTH_USER_MODEL, verbose_name='Владелец')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
46
rsconcept/backend/apps/prompt/models/PromptTemplate.py
Normal file
46
rsconcept/backend/apps/prompt/models/PromptTemplate.py
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
''' Model: PromptTemplate for AI prompt storage and sharing. '''
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from apps.users.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class PromptTemplate(models.Model):
|
||||||
|
'''Represents an AI prompt template, which can be user-owned or shared globally.'''
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
verbose_name='Владелец',
|
||||||
|
to=User,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='prompt_templates'
|
||||||
|
)
|
||||||
|
is_shared = models.BooleanField(
|
||||||
|
verbose_name='Общий доступ',
|
||||||
|
default=False
|
||||||
|
)
|
||||||
|
label = models.CharField(
|
||||||
|
verbose_name='Название',
|
||||||
|
max_length=255
|
||||||
|
)
|
||||||
|
description = models.TextField(
|
||||||
|
verbose_name='Описание',
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
text = models.TextField(
|
||||||
|
verbose_name='Содержание',
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def can_set_shared(self, user: User) -> bool:
|
||||||
|
'''Return True if the user can set is_shared=True (admin/staff only).'''
|
||||||
|
return user.is_superuser or user.is_staff
|
||||||
|
|
||||||
|
def can_access(self, user: User) -> bool:
|
||||||
|
'''Return True if the user can access this template (shared or owner).'''
|
||||||
|
if self.is_shared:
|
||||||
|
return True
|
||||||
|
return self.owner == user
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f'{self.label}'
|
3
rsconcept/backend/apps/prompt/models/__init__.py
Normal file
3
rsconcept/backend/apps/prompt/models/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
''' Django: Models for AI Prompts. '''
|
||||||
|
|
||||||
|
from .PromptTemplate import PromptTemplate
|
2
rsconcept/backend/apps/prompt/serializers/__init__.py
Normal file
2
rsconcept/backend/apps/prompt/serializers/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
''' Serializers for persistent data manipulation (AI Prompts). '''
|
||||||
|
from .data_access import PromptTemplateListSerializer, PromptTemplateSerializer
|
52
rsconcept/backend/apps/prompt/serializers/data_access.py
Normal file
52
rsconcept/backend/apps/prompt/serializers/data_access.py
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
''' Serializers for prompt template data access. '''
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from shared import messages as msg
|
||||||
|
from shared.serializers import StrictModelSerializer
|
||||||
|
|
||||||
|
from ..models import PromptTemplate
|
||||||
|
|
||||||
|
|
||||||
|
class PromptTemplateSerializer(StrictModelSerializer):
|
||||||
|
'''Serializer for PromptTemplate, enforcing permissions and ownership logic.'''
|
||||||
|
class Meta:
|
||||||
|
''' serializer metadata. '''
|
||||||
|
model = PromptTemplate
|
||||||
|
fields = ['id', 'owner', 'is_shared', 'label', 'description', 'text']
|
||||||
|
read_only_fields = ['id', 'owner']
|
||||||
|
|
||||||
|
def validate_label(self, value):
|
||||||
|
user = self.context['request'].user
|
||||||
|
queryset = PromptTemplate.objects.filter(owner=user, label=value)
|
||||||
|
if self.instance is not None:
|
||||||
|
queryset = queryset.exclude(pk=self.instance.pk)
|
||||||
|
if queryset.exists():
|
||||||
|
raise serializers.ValidationError(msg.promptLabelTaken(value))
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate_is_shared(self, value):
|
||||||
|
user = self.context['request'].user
|
||||||
|
if value and not (user.is_superuser or user.is_staff):
|
||||||
|
raise serializers.ValidationError(msg.promptSharedPermissionDenied())
|
||||||
|
return value
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
validated_data['owner'] = self.context['request'].user
|
||||||
|
return super().create(validated_data)
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
user = self.context['request'].user
|
||||||
|
if 'is_shared' in validated_data:
|
||||||
|
if validated_data['is_shared'] and not (user.is_superuser or user.is_staff):
|
||||||
|
raise serializers.ValidationError(msg.promptSharedPermissionDenied())
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
|
class PromptTemplateListSerializer(StrictModelSerializer):
|
||||||
|
'''Serializer for listing PromptTemplates without the 'text' field.'''
|
||||||
|
class Meta:
|
||||||
|
''' serializer metadata. '''
|
||||||
|
model = PromptTemplate
|
||||||
|
fields = ['id', 'owner', 'is_shared', 'label', 'description']
|
||||||
|
read_only_fields = ['id', 'owner']
|
2
rsconcept/backend/apps/prompt/tests/__init__.py
Normal file
2
rsconcept/backend/apps/prompt/tests/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
''' Tests. '''
|
||||||
|
from .t_prompts import *
|
115
rsconcept/backend/apps/prompt/tests/t_prompts.py
Normal file
115
rsconcept/backend/apps/prompt/tests/t_prompts.py
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
''' Testing API: Prompts. '''
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
|
from shared.EndpointTester import EndpointTester, decl_endpoint
|
||||||
|
|
||||||
|
from ..models import PromptTemplate
|
||||||
|
|
||||||
|
|
||||||
|
class TestPromptTemplateViewSet(EndpointTester):
|
||||||
|
''' Testing PromptTemplate viewset. '''
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.admin = self.user2
|
||||||
|
self.admin.is_superuser = True
|
||||||
|
self.admin.save()
|
||||||
|
|
||||||
|
|
||||||
|
@decl_endpoint('/api/prompts/', method='post')
|
||||||
|
def test_create_prompt(self):
|
||||||
|
data = {
|
||||||
|
'label': 'Test',
|
||||||
|
'description': 'desc',
|
||||||
|
'text': 'prompt text',
|
||||||
|
'is_shared': False
|
||||||
|
}
|
||||||
|
response = self.executeCreated(data=data)
|
||||||
|
self.assertEqual(response.data['label'], 'Test')
|
||||||
|
self.assertEqual(response.data['owner'], self.user.pk)
|
||||||
|
|
||||||
|
|
||||||
|
@decl_endpoint('/api/prompts/', method='post')
|
||||||
|
def test_create_shared_prompt_by_admin(self):
|
||||||
|
self.client.force_authenticate(user=self.admin)
|
||||||
|
data = {
|
||||||
|
'label': 'Shared',
|
||||||
|
'description': 'desc',
|
||||||
|
'text': 'prompt text',
|
||||||
|
'is_shared': True
|
||||||
|
}
|
||||||
|
response = self.executeCreated(data=data)
|
||||||
|
self.assertTrue(response.data['is_shared'])
|
||||||
|
|
||||||
|
|
||||||
|
@decl_endpoint('/api/prompts/', method='post')
|
||||||
|
def test_create_shared_prompt_by_user_forbidden(self):
|
||||||
|
data = {
|
||||||
|
'label': 'Shared',
|
||||||
|
'description': 'desc',
|
||||||
|
'text': 'prompt text',
|
||||||
|
'is_shared': True
|
||||||
|
}
|
||||||
|
response = self.executeBadData(data=data)
|
||||||
|
self.assertIn('is_shared', response.data)
|
||||||
|
|
||||||
|
|
||||||
|
@decl_endpoint('/api/prompts/{item}/', method='patch')
|
||||||
|
def test_update_prompt_owner(self):
|
||||||
|
prompt = PromptTemplate.objects.create(owner=self.user, label='ToUpdate', description='', text='t')
|
||||||
|
response = self.executeOK(data={'label': 'Updated'}, item=prompt.id)
|
||||||
|
self.assertEqual(response.data['label'], 'Updated')
|
||||||
|
|
||||||
|
|
||||||
|
@decl_endpoint('/api/prompts/{item}/', method='patch')
|
||||||
|
def test_update_prompt_not_owner_forbidden(self):
|
||||||
|
prompt = PromptTemplate.objects.create(owner=self.admin, label='Other', description='', text='t')
|
||||||
|
response = self.executeForbidden(data={'label': 'Updated'}, item=prompt.id)
|
||||||
|
|
||||||
|
|
||||||
|
@decl_endpoint('/api/prompts/{item}/', method='delete')
|
||||||
|
def test_delete_prompt_owner(self):
|
||||||
|
prompt = PromptTemplate.objects.create(owner=self.user, label='ToDelete', description='', text='t')
|
||||||
|
self.executeNoContent(item=prompt.id)
|
||||||
|
|
||||||
|
|
||||||
|
@decl_endpoint('/api/prompts/{item}/', method='delete')
|
||||||
|
def test_delete_prompt_not_owner_forbidden(self):
|
||||||
|
prompt = PromptTemplate.objects.create(owner=self.admin, label='Other2', description='', text='t')
|
||||||
|
self.executeForbidden(item=prompt.id)
|
||||||
|
|
||||||
|
|
||||||
|
@decl_endpoint('/api/prompts/available/', method='get')
|
||||||
|
def test_available_endpoint(self):
|
||||||
|
PromptTemplate.objects.create(
|
||||||
|
owner=self.user,
|
||||||
|
label='Mine',
|
||||||
|
description='',
|
||||||
|
text='t'
|
||||||
|
)
|
||||||
|
PromptTemplate.objects.create(
|
||||||
|
owner=self.admin,
|
||||||
|
label='Shared',
|
||||||
|
description='',
|
||||||
|
text='t',
|
||||||
|
is_shared=True
|
||||||
|
)
|
||||||
|
response = self.executeOK()
|
||||||
|
labels = [item['label'] for item in response.data]
|
||||||
|
self.assertIn('Mine', labels)
|
||||||
|
self.assertIn('Shared', labels)
|
||||||
|
for item in response.data:
|
||||||
|
self.assertNotIn('text', item)
|
||||||
|
|
||||||
|
|
||||||
|
@decl_endpoint('/api/prompts/{item}/', method='patch')
|
||||||
|
def test_permissions_on_shared(self):
|
||||||
|
prompt = PromptTemplate.objects.create(
|
||||||
|
owner=self.admin,
|
||||||
|
label='Shared',
|
||||||
|
description='',
|
||||||
|
text='t',
|
||||||
|
is_shared=True
|
||||||
|
)
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
response = self.executeForbidden(data={'label': 'Nope'}, item=prompt.id)
|
9
rsconcept/backend/apps/prompt/urls.py
Normal file
9
rsconcept/backend/apps/prompt/urls.py
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
''' Routing: Prompts for AI helper. '''
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from .views import PromptTemplateViewSet
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register('prompts', PromptTemplateViewSet, 'prompt-template')
|
||||||
|
|
||||||
|
urlpatterns = router.urls
|
2
rsconcept/backend/apps/prompt/views/__init__.py
Normal file
2
rsconcept/backend/apps/prompt/views/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
''' REST API: Endpoint processors for AI Prompts. '''
|
||||||
|
from .prompts import PromptTemplateViewSet
|
64
rsconcept/backend/apps/prompt/views/prompts.py
Normal file
64
rsconcept/backend/apps/prompt/views/prompts.py
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
''' Views: PromptTemplate endpoints for AI prompt management. '''
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from drf_spectacular.utils import extend_schema
|
||||||
|
from rest_framework import permissions, viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from ..models import PromptTemplate
|
||||||
|
from ..serializers import PromptTemplateListSerializer, PromptTemplateSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class IsOwnerOrAdmin(permissions.BasePermission):
|
||||||
|
'''Permission: Only owner or admin can modify, anyone can view shared.'''
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj):
|
||||||
|
if request.method in permissions.SAFE_METHODS:
|
||||||
|
# Allow unauthenticated users to view only shared templates
|
||||||
|
if not request.user or not request.user.is_authenticated:
|
||||||
|
return obj.is_shared
|
||||||
|
return obj.is_shared or obj.owner == request.user
|
||||||
|
return obj.owner == request.user or request.user.is_staff or request.user.is_superuser
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(tags=['Prompts'])
|
||||||
|
class PromptTemplateViewSet(viewsets.ModelViewSet):
|
||||||
|
'''ViewSet: CRUD and listing for PromptTemplate, with sharing logic.'''
|
||||||
|
queryset = PromptTemplate.objects.all()
|
||||||
|
serializer_class = PromptTemplateSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated, IsOwnerOrAdmin]
|
||||||
|
|
||||||
|
def get_permissions(self):
|
||||||
|
if self.action in ['available', 'retrieve']:
|
||||||
|
return [AllowAny()]
|
||||||
|
return [permission() for permission in self.permission_classes]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
user = self.request.user
|
||||||
|
if self.action == 'available':
|
||||||
|
return PromptTemplate.objects.none()
|
||||||
|
return PromptTemplate.objects.filter(models.Q(owner=user) | models.Q(is_shared=True)).distinct()
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
obj = PromptTemplate.objects.get(pk=self.kwargs['pk'])
|
||||||
|
self.check_object_permissions(self.request, obj)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
@extend_schema(summary='List user-owned and shared prompt templates')
|
||||||
|
@action(detail=False, methods=['get'], url_path='available')
|
||||||
|
def available(self, request):
|
||||||
|
'''Return user-owned and shared prompt templates.'''
|
||||||
|
user = request.user
|
||||||
|
if user.is_authenticated:
|
||||||
|
owned = PromptTemplate.objects.filter(owner=user)
|
||||||
|
shared = PromptTemplate.objects.filter(is_shared=True)
|
||||||
|
templates = (owned | shared).distinct()
|
||||||
|
else:
|
||||||
|
templates = PromptTemplate.objects.filter(is_shared=True)
|
||||||
|
serializer = PromptTemplateListSerializer(templates, many=True)
|
||||||
|
return Response(serializer.data)
|
|
@ -4,11 +4,9 @@ from django.contrib import admin
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(models.Constituenta)
|
||||||
class ConstituentaAdmin(admin.ModelAdmin):
|
class ConstituentaAdmin(admin.ModelAdmin):
|
||||||
''' Admin model: Constituenta. '''
|
''' Admin model: Constituenta. '''
|
||||||
ordering = ['schema', 'order']
|
ordering = ['schema', 'order']
|
||||||
list_display = ['schema', 'order', 'alias', 'term_resolved', 'definition_resolved']
|
list_display = ['schema', 'order', 'alias', 'term_resolved', 'definition_resolved']
|
||||||
search_fields = ['term_resolved', 'definition_resolved']
|
search_fields = ['term_resolved', 'definition_resolved']
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(models.Constituenta, ConstituentaAdmin)
|
|
||||||
|
|
|
@ -16,7 +16,6 @@ from .data_access import (
|
||||||
CstInfoSerializer,
|
CstInfoSerializer,
|
||||||
CstListSerializer,
|
CstListSerializer,
|
||||||
CstMoveSerializer,
|
CstMoveSerializer,
|
||||||
CstRenameSerializer,
|
|
||||||
CstSubstituteSerializer,
|
CstSubstituteSerializer,
|
||||||
CstTargetSerializer,
|
CstTargetSerializer,
|
||||||
CstUpdateSerializer,
|
CstUpdateSerializer,
|
||||||
|
|
|
@ -4,26 +4,28 @@ from typing import cast
|
||||||
from cctext import EntityReference, Reference, ReferenceType, Resolver, SyntacticReference
|
from cctext import EntityReference, Reference, ReferenceType, Resolver, SyntacticReference
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from shared.serializers import StrictSerializer
|
||||||
|
|
||||||
class ExpressionSerializer(serializers.Serializer):
|
|
||||||
|
class ExpressionSerializer(StrictSerializer):
|
||||||
''' Serializer: RSLang expression. '''
|
''' Serializer: RSLang expression. '''
|
||||||
expression = serializers.CharField()
|
expression = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
class ConstituentaCheckSerializer(serializers.Serializer):
|
class ConstituentaCheckSerializer(StrictSerializer):
|
||||||
''' Serializer: RSLang expression. '''
|
''' Serializer: RSLang expression. '''
|
||||||
alias = serializers.CharField()
|
alias = serializers.CharField()
|
||||||
definition_formal = serializers.CharField(allow_blank=True)
|
definition_formal = serializers.CharField(allow_blank=True)
|
||||||
cst_type = serializers.CharField()
|
cst_type = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
class WordFormSerializer(serializers.Serializer):
|
class WordFormSerializer(StrictSerializer):
|
||||||
''' Serializer: inflect request. '''
|
''' Serializer: inflect request. '''
|
||||||
text = serializers.CharField()
|
text = serializers.CharField()
|
||||||
grams = serializers.CharField()
|
grams = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
class MultiFormSerializer(serializers.Serializer):
|
class MultiFormSerializer(StrictSerializer):
|
||||||
''' Serializer: inflect request. '''
|
''' Serializer: inflect request. '''
|
||||||
items = serializers.ListField(
|
items = serializers.ListField(
|
||||||
child=WordFormSerializer()
|
child=WordFormSerializer()
|
||||||
|
@ -41,18 +43,18 @@ class MultiFormSerializer(serializers.Serializer):
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
class TextSerializer(serializers.Serializer):
|
class TextSerializer(StrictSerializer):
|
||||||
''' Serializer: Text with references. '''
|
''' Serializer: Text with references. '''
|
||||||
text = serializers.CharField()
|
text = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
class FunctionArgSerializer(serializers.Serializer):
|
class FunctionArgSerializer(StrictSerializer):
|
||||||
''' Serializer: RSLang function argument type. '''
|
''' Serializer: RSLang function argument type. '''
|
||||||
alias = serializers.CharField()
|
alias = serializers.CharField()
|
||||||
typification = serializers.CharField()
|
typification = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
class CstParseSerializer(serializers.Serializer):
|
class CstParseSerializer(StrictSerializer):
|
||||||
''' Serializer: Constituenta parse result. '''
|
''' Serializer: Constituenta parse result. '''
|
||||||
status = serializers.CharField()
|
status = serializers.CharField()
|
||||||
valueClass = serializers.CharField()
|
valueClass = serializers.CharField()
|
||||||
|
@ -63,7 +65,7 @@ class CstParseSerializer(serializers.Serializer):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ErrorDescriptionSerializer(serializers.Serializer):
|
class ErrorDescriptionSerializer(StrictSerializer):
|
||||||
''' Serializer: RSError description. '''
|
''' Serializer: RSError description. '''
|
||||||
errorType = serializers.IntegerField()
|
errorType = serializers.IntegerField()
|
||||||
position = serializers.IntegerField()
|
position = serializers.IntegerField()
|
||||||
|
@ -73,13 +75,13 @@ class ErrorDescriptionSerializer(serializers.Serializer):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class NodeDataSerializer(serializers.Serializer):
|
class NodeDataSerializer(StrictSerializer):
|
||||||
''' Serializer: Node data. '''
|
''' Serializer: Node data. '''
|
||||||
dataType = serializers.CharField()
|
dataType = serializers.CharField()
|
||||||
value = serializers.CharField()
|
value = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
class ASTNodeSerializer(serializers.Serializer):
|
class ASTNodeSerializer(StrictSerializer):
|
||||||
''' Serializer: Syntax tree node. '''
|
''' Serializer: Syntax tree node. '''
|
||||||
uid = serializers.IntegerField()
|
uid = serializers.IntegerField()
|
||||||
parent = serializers.IntegerField() # type: ignore
|
parent = serializers.IntegerField() # type: ignore
|
||||||
|
@ -89,7 +91,7 @@ class ASTNodeSerializer(serializers.Serializer):
|
||||||
data = NodeDataSerializer() # type: ignore
|
data = NodeDataSerializer() # type: ignore
|
||||||
|
|
||||||
|
|
||||||
class ExpressionParseSerializer(serializers.Serializer):
|
class ExpressionParseSerializer(StrictSerializer):
|
||||||
''' Serializer: RSlang expression parse result. '''
|
''' Serializer: RSlang expression parse result. '''
|
||||||
parseResult = serializers.BooleanField()
|
parseResult = serializers.BooleanField()
|
||||||
prefixLen = serializers.IntegerField()
|
prefixLen = serializers.IntegerField()
|
||||||
|
@ -108,13 +110,13 @@ class ExpressionParseSerializer(serializers.Serializer):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TextPositionSerializer(serializers.Serializer):
|
class TextPositionSerializer(StrictSerializer):
|
||||||
''' Serializer: Text position. '''
|
''' Serializer: Text position. '''
|
||||||
start = serializers.IntegerField()
|
start = serializers.IntegerField()
|
||||||
finish = serializers.IntegerField()
|
finish = serializers.IntegerField()
|
||||||
|
|
||||||
|
|
||||||
class ReferenceDataSerializer(serializers.Serializer):
|
class ReferenceDataSerializer(StrictSerializer):
|
||||||
''' Serializer: Reference data - Union of all references. '''
|
''' Serializer: Reference data - Union of all references. '''
|
||||||
offset = serializers.IntegerField()
|
offset = serializers.IntegerField()
|
||||||
nominal = serializers.CharField()
|
nominal = serializers.CharField()
|
||||||
|
@ -122,7 +124,7 @@ class ReferenceDataSerializer(serializers.Serializer):
|
||||||
form = serializers.CharField()
|
form = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
class ReferenceSerializer(serializers.Serializer):
|
class ReferenceSerializer(StrictSerializer):
|
||||||
''' Serializer: Language reference. '''
|
''' Serializer: Language reference. '''
|
||||||
type = serializers.CharField()
|
type = serializers.CharField()
|
||||||
data = ReferenceDataSerializer() # type: ignore
|
data = ReferenceDataSerializer() # type: ignore
|
||||||
|
@ -130,7 +132,7 @@ class ReferenceSerializer(serializers.Serializer):
|
||||||
pos_output = TextPositionSerializer()
|
pos_output = TextPositionSerializer()
|
||||||
|
|
||||||
|
|
||||||
class InheritanceDataSerializer(serializers.Serializer):
|
class InheritanceDataSerializer(StrictSerializer):
|
||||||
''' Serializer: inheritance data. '''
|
''' Serializer: inheritance data. '''
|
||||||
child = serializers.IntegerField()
|
child = serializers.IntegerField()
|
||||||
child_source = serializers.IntegerField()
|
child_source = serializers.IntegerField()
|
||||||
|
@ -138,7 +140,7 @@ class InheritanceDataSerializer(serializers.Serializer):
|
||||||
parent_source = serializers.IntegerField()
|
parent_source = serializers.IntegerField()
|
||||||
|
|
||||||
|
|
||||||
class ResolverSerializer(serializers.Serializer):
|
class ResolverSerializer(StrictSerializer):
|
||||||
''' Serializer: Resolver results serializer. '''
|
''' Serializer: Resolver results serializer. '''
|
||||||
input = serializers.CharField()
|
input = serializers.CharField()
|
||||||
output = serializers.CharField()
|
output = serializers.CharField()
|
||||||
|
|
|
@ -9,19 +9,20 @@ from rest_framework.serializers import PrimaryKeyRelatedField as PKField
|
||||||
|
|
||||||
from apps.library.models import LibraryItem
|
from apps.library.models import LibraryItem
|
||||||
from apps.library.serializers import (
|
from apps.library.serializers import (
|
||||||
LibraryItemBaseSerializer,
|
LibraryItemBaseNonStrictSerializer,
|
||||||
LibraryItemDetailsSerializer,
|
LibraryItemDetailsSerializer,
|
||||||
LibraryItemReferenceSerializer
|
LibraryItemReferenceSerializer
|
||||||
)
|
)
|
||||||
from apps.oss.models import Inheritance
|
from apps.oss.models import Inheritance
|
||||||
from shared import messages as msg
|
from shared import messages as msg
|
||||||
|
from shared.serializers import StrictModelSerializer, StrictSerializer
|
||||||
|
|
||||||
from ..models import Constituenta, CstType, RSForm
|
from ..models import Constituenta, CstType, RSForm
|
||||||
from .basics import CstParseSerializer, InheritanceDataSerializer
|
from .basics import CstParseSerializer, InheritanceDataSerializer
|
||||||
from .io_pyconcept import PyConceptAdapter
|
from .io_pyconcept import PyConceptAdapter
|
||||||
|
|
||||||
|
|
||||||
class CstBaseSerializer(serializers.ModelSerializer):
|
class CstBaseSerializer(StrictModelSerializer):
|
||||||
''' Serializer: Constituenta all data. '''
|
''' Serializer: Constituenta all data. '''
|
||||||
class Meta:
|
class Meta:
|
||||||
''' serializer metadata. '''
|
''' serializer metadata. '''
|
||||||
|
@ -30,7 +31,7 @@ class CstBaseSerializer(serializers.ModelSerializer):
|
||||||
read_only_fields = ('id',)
|
read_only_fields = ('id',)
|
||||||
|
|
||||||
|
|
||||||
class CstInfoSerializer(serializers.ModelSerializer):
|
class CstInfoSerializer(StrictModelSerializer):
|
||||||
''' Serializer: Constituenta public information. '''
|
''' Serializer: Constituenta public information. '''
|
||||||
class Meta:
|
class Meta:
|
||||||
''' serializer metadata. '''
|
''' serializer metadata. '''
|
||||||
|
@ -38,18 +39,19 @@ class CstInfoSerializer(serializers.ModelSerializer):
|
||||||
exclude = ('order', 'schema')
|
exclude = ('order', 'schema')
|
||||||
|
|
||||||
|
|
||||||
class CstUpdateSerializer(serializers.Serializer):
|
class CstUpdateSerializer(StrictSerializer):
|
||||||
''' Serializer: Constituenta update. '''
|
''' Serializer: Constituenta update. '''
|
||||||
class ConstituentaUpdateData(serializers.ModelSerializer):
|
class ConstituentaUpdateData(StrictModelSerializer):
|
||||||
''' Serializer: Operation creation data. '''
|
''' Serializer: Operation creation data. '''
|
||||||
class Meta:
|
class Meta:
|
||||||
''' serializer metadata. '''
|
''' serializer metadata. '''
|
||||||
model = Constituenta
|
model = Constituenta
|
||||||
fields = 'convention', 'definition_formal', 'definition_raw', 'term_raw', 'term_forms'
|
fields = 'alias', 'cst_type', 'convention', 'definition_formal', 'definition_raw', 'term_raw', 'term_forms'
|
||||||
|
|
||||||
target = PKField(
|
target = PKField(
|
||||||
many=False,
|
many=False,
|
||||||
queryset=Constituenta.objects.all().only('convention', 'definition_formal', 'definition_raw', 'term_raw')
|
queryset=Constituenta.objects.all().only(
|
||||||
|
'alias', 'cst_type', 'convention', 'definition_formal', 'definition_raw', 'term_raw')
|
||||||
)
|
)
|
||||||
item_data = ConstituentaUpdateData()
|
item_data = ConstituentaUpdateData()
|
||||||
|
|
||||||
|
@ -60,10 +62,16 @@ class CstUpdateSerializer(serializers.Serializer):
|
||||||
raise serializers.ValidationError({
|
raise serializers.ValidationError({
|
||||||
f'{cst.pk}': msg.constituentaNotInRSform(schema.title)
|
f'{cst.pk}': msg.constituentaNotInRSform(schema.title)
|
||||||
})
|
})
|
||||||
|
if 'alias' in attrs['item_data']:
|
||||||
|
new_alias = attrs['item_data']['alias']
|
||||||
|
if cst.alias != new_alias and RSForm(schema).constituents().filter(alias=new_alias).exists():
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
'alias': msg.aliasTaken(new_alias)
|
||||||
|
})
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class CstDetailsSerializer(serializers.ModelSerializer):
|
class CstDetailsSerializer(StrictModelSerializer):
|
||||||
''' Serializer: Constituenta data including parse. '''
|
''' Serializer: Constituenta data including parse. '''
|
||||||
parse = CstParseSerializer()
|
parse = CstParseSerializer()
|
||||||
|
|
||||||
|
@ -73,7 +81,7 @@ class CstDetailsSerializer(serializers.ModelSerializer):
|
||||||
exclude = ('order',)
|
exclude = ('order',)
|
||||||
|
|
||||||
|
|
||||||
class CstCreateSerializer(serializers.ModelSerializer):
|
class CstCreateSerializer(StrictModelSerializer):
|
||||||
''' Serializer: Constituenta creation. '''
|
''' Serializer: Constituenta creation. '''
|
||||||
insert_after = PKField(
|
insert_after = PKField(
|
||||||
many=False,
|
many=False,
|
||||||
|
@ -93,7 +101,7 @@ class CstCreateSerializer(serializers.ModelSerializer):
|
||||||
'insert_after', 'term_forms'
|
'insert_after', 'term_forms'
|
||||||
|
|
||||||
|
|
||||||
class RSFormSerializer(serializers.ModelSerializer):
|
class RSFormSerializer(StrictModelSerializer):
|
||||||
''' Serializer: Detailed data for RSForm. '''
|
''' Serializer: Detailed data for RSForm. '''
|
||||||
editors = serializers.ListField(
|
editors = serializers.ListField(
|
||||||
child=serializers.IntegerField()
|
child=serializers.IntegerField()
|
||||||
|
@ -201,7 +209,7 @@ class RSFormSerializer(serializers.ModelSerializer):
|
||||||
validated_data=new_cst.validated_data
|
validated_data=new_cst.validated_data
|
||||||
)
|
)
|
||||||
|
|
||||||
loaded_item = LibraryItemBaseSerializer(data=data)
|
loaded_item = LibraryItemBaseNonStrictSerializer(data=data)
|
||||||
loaded_item.is_valid(raise_exception=True)
|
loaded_item.is_valid(raise_exception=True)
|
||||||
loaded_item.update(
|
loaded_item.update(
|
||||||
instance=cast(LibraryItem, self.instance),
|
instance=cast(LibraryItem, self.instance),
|
||||||
|
@ -209,7 +217,7 @@ class RSFormSerializer(serializers.ModelSerializer):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class RSFormParseSerializer(serializers.ModelSerializer):
|
class RSFormParseSerializer(StrictModelSerializer):
|
||||||
''' Serializer: Detailed data for RSForm including parse. '''
|
''' Serializer: Detailed data for RSForm including parse. '''
|
||||||
editors = serializers.ListField(
|
editors = serializers.ListField(
|
||||||
child=serializers.IntegerField()
|
child=serializers.IntegerField()
|
||||||
|
@ -243,7 +251,7 @@ class RSFormParseSerializer(serializers.ModelSerializer):
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
class CstTargetSerializer(serializers.Serializer):
|
class CstTargetSerializer(StrictSerializer):
|
||||||
''' Serializer: Target single Constituenta. '''
|
''' Serializer: Target single Constituenta. '''
|
||||||
target = PKField(many=False, queryset=Constituenta.objects.all())
|
target = PKField(many=False, queryset=Constituenta.objects.all())
|
||||||
|
|
||||||
|
@ -258,33 +266,7 @@ class CstTargetSerializer(serializers.Serializer):
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class CstRenameSerializer(serializers.Serializer):
|
class CstListSerializer(StrictSerializer):
|
||||||
''' Serializer: Constituenta renaming. '''
|
|
||||||
target = PKField(many=False, queryset=Constituenta.objects.only('alias', 'cst_type', 'schema'))
|
|
||||||
alias = serializers.CharField()
|
|
||||||
cst_type = serializers.CharField()
|
|
||||||
|
|
||||||
def validate(self, attrs):
|
|
||||||
attrs = super().validate(attrs)
|
|
||||||
schema = cast(LibraryItem, self.context['schema'])
|
|
||||||
cst = cast(Constituenta, attrs['target'])
|
|
||||||
if cst.schema_id != schema.pk:
|
|
||||||
raise serializers.ValidationError({
|
|
||||||
f'{cst.pk}': msg.constituentaNotInRSform(schema.title)
|
|
||||||
})
|
|
||||||
new_alias = self.initial_data['alias']
|
|
||||||
if cst.alias == new_alias:
|
|
||||||
raise serializers.ValidationError({
|
|
||||||
'alias': msg.renameTrivial(new_alias)
|
|
||||||
})
|
|
||||||
if RSForm(schema).constituents().filter(alias=new_alias).exists():
|
|
||||||
raise serializers.ValidationError({
|
|
||||||
'alias': msg.aliasTaken(new_alias)
|
|
||||||
})
|
|
||||||
return attrs
|
|
||||||
|
|
||||||
|
|
||||||
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().only('schema_id'))
|
items = PKField(many=True, queryset=Constituenta.objects.all().only('schema_id'))
|
||||||
|
|
||||||
|
@ -306,13 +288,13 @@ class CstMoveSerializer(CstListSerializer):
|
||||||
move_to = serializers.IntegerField()
|
move_to = serializers.IntegerField()
|
||||||
|
|
||||||
|
|
||||||
class SubstitutionSerializerBase(serializers.Serializer):
|
class SubstitutionSerializerBase(StrictSerializer):
|
||||||
''' Serializer: Basic substitution. '''
|
''' Serializer: Basic substitution. '''
|
||||||
original = PKField(many=False, queryset=Constituenta.objects.only('alias', 'schema_id'))
|
original = PKField(many=False, queryset=Constituenta.objects.only('alias', 'schema_id'))
|
||||||
substitution = PKField(many=False, queryset=Constituenta.objects.only('alias', 'schema_id'))
|
substitution = PKField(many=False, queryset=Constituenta.objects.only('alias', 'schema_id'))
|
||||||
|
|
||||||
|
|
||||||
class CstSubstituteSerializer(serializers.Serializer):
|
class CstSubstituteSerializer(StrictSerializer):
|
||||||
''' Serializer: Constituenta substitution. '''
|
''' Serializer: Constituenta substitution. '''
|
||||||
substitutions = serializers.ListField(
|
substitutions = serializers.ListField(
|
||||||
child=SubstitutionSerializerBase(),
|
child=SubstitutionSerializerBase(),
|
||||||
|
@ -345,7 +327,7 @@ class CstSubstituteSerializer(serializers.Serializer):
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class InlineSynthesisSerializer(serializers.Serializer):
|
class InlineSynthesisSerializer(StrictSerializer):
|
||||||
''' Serializer: Inline synthesis operation input. '''
|
''' Serializer: Inline synthesis operation input. '''
|
||||||
receiver = PKField(many=False, queryset=LibraryItem.objects.all().only('owner_id'))
|
receiver = PKField(many=False, queryset=LibraryItem.objects.all().only('owner_id'))
|
||||||
source = PKField(many=False, queryset=LibraryItem.objects.all().only('owner_id')) # type: ignore
|
source = PKField(many=False, queryset=LibraryItem.objects.all().only('owner_id')) # type: ignore
|
||||||
|
|
|
@ -4,6 +4,7 @@ from rest_framework import serializers
|
||||||
|
|
||||||
from apps.library.models import LibraryItem
|
from apps.library.models import LibraryItem
|
||||||
from shared import messages as msg
|
from shared import messages as msg
|
||||||
|
from shared.serializers import StrictSerializer
|
||||||
|
|
||||||
from ..models import Constituenta, RSForm
|
from ..models import Constituenta, RSForm
|
||||||
from ..utils import fix_old_references
|
from ..utils import fix_old_references
|
||||||
|
@ -15,12 +16,12 @@ _TRS_VERSION = 16
|
||||||
_TRS_HEADER = 'Exteor 4.8.13.1000 - 30/05/2022'
|
_TRS_HEADER = 'Exteor 4.8.13.1000 - 30/05/2022'
|
||||||
|
|
||||||
|
|
||||||
class FileSerializer(serializers.Serializer):
|
class FileSerializer(StrictSerializer):
|
||||||
''' Serializer: File input. '''
|
''' Serializer: File input. '''
|
||||||
file = serializers.FileField(allow_empty_file=False)
|
file = serializers.FileField(allow_empty_file=False)
|
||||||
|
|
||||||
|
|
||||||
class RSFormUploadSerializer(serializers.Serializer):
|
class RSFormUploadSerializer(StrictSerializer):
|
||||||
''' Upload data for RSForm serializer. '''
|
''' Upload data for RSForm serializer. '''
|
||||||
file = serializers.FileField()
|
file = serializers.FileField()
|
||||||
load_metadata = serializers.BooleanField()
|
load_metadata = serializers.BooleanField()
|
||||||
|
|
|
@ -1,21 +1,23 @@
|
||||||
''' Utility serializers for REST API schema - SHOULD NOT BE ACCESSED DIRECTLY. '''
|
''' Utility serializers for REST API schema - SHOULD NOT BE ACCESSED DIRECTLY. '''
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from shared.serializers import StrictSerializer
|
||||||
|
|
||||||
from .data_access import RSFormParseSerializer
|
from .data_access import RSFormParseSerializer
|
||||||
|
|
||||||
|
|
||||||
class ResultTextResponse(serializers.Serializer):
|
class ResultTextResponse(StrictSerializer):
|
||||||
''' Serializer: Text result of a function call. '''
|
''' Serializer: Text result of a function call. '''
|
||||||
result = serializers.CharField()
|
result = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
class NewCstResponse(serializers.Serializer):
|
class NewCstResponse(StrictSerializer):
|
||||||
''' Serializer: Create cst response. '''
|
''' Serializer: Create cst response. '''
|
||||||
new_cst = serializers.IntegerField()
|
new_cst = serializers.IntegerField()
|
||||||
schema = RSFormParseSerializer()
|
schema = RSFormParseSerializer()
|
||||||
|
|
||||||
|
|
||||||
class NewMultiCstResponse(serializers.Serializer):
|
class NewMultiCstResponse(StrictSerializer):
|
||||||
''' Serializer: Create multiple cst response. '''
|
''' Serializer: Create multiple cst response. '''
|
||||||
cst_list = serializers.ListField(
|
cst_list = serializers.ListField(
|
||||||
child=serializers.IntegerField()
|
child=serializers.IntegerField()
|
||||||
|
|
|
@ -244,56 +244,6 @@ class TestRSFormViewset(EndpointTester):
|
||||||
self.assertEqual(response.data['new_cst']['alias'], data['alias'])
|
self.assertEqual(response.data['new_cst']['alias'], data['alias'])
|
||||||
|
|
||||||
|
|
||||||
@decl_endpoint('/api/rsforms/{item}/rename-cst', method='patch')
|
|
||||||
def test_rename_constituenta(self):
|
|
||||||
x1 = self.owned.insert_new(
|
|
||||||
alias='X1',
|
|
||||||
convention='Test',
|
|
||||||
term_raw='Test1',
|
|
||||||
term_resolved='Test1',
|
|
||||||
term_forms=[{'text': 'form1', 'tags': 'sing,datv'}]
|
|
||||||
)
|
|
||||||
x2_2 = self.unowned.insert_new('X2')
|
|
||||||
x3 = self.owned.insert_new(
|
|
||||||
alias='X3',
|
|
||||||
term_raw='Test3',
|
|
||||||
term_resolved='Test3',
|
|
||||||
definition_raw='Test1',
|
|
||||||
definition_resolved='Test2'
|
|
||||||
)
|
|
||||||
|
|
||||||
data = {'target': x2_2.pk, 'alias': 'D2', 'cst_type': CstType.TERM}
|
|
||||||
self.executeForbidden(data=data, item=self.unowned_id)
|
|
||||||
self.executeBadData(data=data, item=self.owned_id)
|
|
||||||
|
|
||||||
data = {'target': x1.pk, 'alias': x1.alias, 'cst_type': CstType.TERM}
|
|
||||||
self.executeBadData(data=data, item=self.owned_id)
|
|
||||||
|
|
||||||
data = {'target': x1.pk, 'alias': x3.alias}
|
|
||||||
self.executeBadData(data=data, item=self.owned_id)
|
|
||||||
|
|
||||||
d1 = self.owned.insert_new(
|
|
||||||
alias='D1',
|
|
||||||
term_raw='@{X1|plur}',
|
|
||||||
definition_formal='X1'
|
|
||||||
)
|
|
||||||
self.assertEqual(x1.order, 0)
|
|
||||||
self.assertEqual(x1.alias, 'X1')
|
|
||||||
self.assertEqual(x1.cst_type, CstType.BASE)
|
|
||||||
|
|
||||||
data = {'target': x1.pk, 'alias': 'D2', 'cst_type': CstType.TERM}
|
|
||||||
response = self.executeOK(data=data, item=self.owned_id)
|
|
||||||
self.assertEqual(response.data['new_cst']['alias'], 'D2')
|
|
||||||
self.assertEqual(response.data['new_cst']['cst_type'], CstType.TERM)
|
|
||||||
d1.refresh_from_db()
|
|
||||||
x1.refresh_from_db()
|
|
||||||
self.assertEqual(d1.term_resolved, '')
|
|
||||||
self.assertEqual(d1.term_raw, '@{D2|plur}')
|
|
||||||
self.assertEqual(x1.order, 0)
|
|
||||||
self.assertEqual(x1.alias, 'D2')
|
|
||||||
self.assertEqual(x1.cst_type, CstType.TERM)
|
|
||||||
|
|
||||||
|
|
||||||
@decl_endpoint('/api/rsforms/{item}/substitute', method='patch')
|
@decl_endpoint('/api/rsforms/{item}/substitute', method='patch')
|
||||||
def test_substitute_multiple(self):
|
def test_substitute_multiple(self):
|
||||||
self.set_params(item=self.owned_id)
|
self.set_params(item=self.owned_id)
|
||||||
|
@ -507,12 +457,14 @@ class TestConstituentaAPI(EndpointTester):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.rsform_owned = RSForm.create(title='Test', alias='T1', owner=self.user)
|
self.owned = RSForm.create(title='Test', alias='T1', owner=self.user)
|
||||||
self.rsform_unowned = RSForm.create(title='Test2', alias='T2')
|
self.owned_id = self.owned.model.pk
|
||||||
|
self.unowned = RSForm.create(title='Test2', alias='T2')
|
||||||
|
self.unowned_id = self.unowned.model.pk
|
||||||
self.cst1 = Constituenta.objects.create(
|
self.cst1 = Constituenta.objects.create(
|
||||||
alias='X1',
|
alias='X1',
|
||||||
cst_type=CstType.BASE,
|
cst_type=CstType.BASE,
|
||||||
schema=self.rsform_owned.model,
|
schema=self.owned.model,
|
||||||
order=0,
|
order=0,
|
||||||
convention='Test',
|
convention='Test',
|
||||||
term_raw='Test1',
|
term_raw='Test1',
|
||||||
|
@ -521,7 +473,7 @@ class TestConstituentaAPI(EndpointTester):
|
||||||
self.cst2 = Constituenta.objects.create(
|
self.cst2 = Constituenta.objects.create(
|
||||||
alias='X2',
|
alias='X2',
|
||||||
cst_type=CstType.BASE,
|
cst_type=CstType.BASE,
|
||||||
schema=self.rsform_unowned.model,
|
schema=self.unowned.model,
|
||||||
order=0,
|
order=0,
|
||||||
convention='Test1',
|
convention='Test1',
|
||||||
term_raw='Test2',
|
term_raw='Test2',
|
||||||
|
@ -529,7 +481,7 @@ class TestConstituentaAPI(EndpointTester):
|
||||||
)
|
)
|
||||||
self.cst3 = Constituenta.objects.create(
|
self.cst3 = Constituenta.objects.create(
|
||||||
alias='X3',
|
alias='X3',
|
||||||
schema=self.rsform_owned.model,
|
schema=self.owned.model,
|
||||||
order=1,
|
order=1,
|
||||||
term_raw='Test3',
|
term_raw='Test3',
|
||||||
term_resolved='Test3',
|
term_resolved='Test3',
|
||||||
|
@ -541,18 +493,42 @@ class TestConstituentaAPI(EndpointTester):
|
||||||
@decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch')
|
@decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch')
|
||||||
def test_partial_update(self):
|
def test_partial_update(self):
|
||||||
data = {'target': self.cst1.pk, 'item_data': {'convention': 'tt'}}
|
data = {'target': self.cst1.pk, 'item_data': {'convention': 'tt'}}
|
||||||
self.executeForbidden(data=data, schema=self.rsform_unowned.model.pk)
|
self.executeForbidden(data=data, schema=self.unowned_id)
|
||||||
|
|
||||||
self.logout()
|
self.logout()
|
||||||
self.executeForbidden(data=data, schema=self.rsform_owned.model.pk)
|
self.executeForbidden(data=data, schema=self.owned_id)
|
||||||
|
|
||||||
self.login()
|
self.login()
|
||||||
response = self.executeOK(data=data, schema=self.rsform_owned.model.pk)
|
self.executeOK(data=data, schema=self.owned_id)
|
||||||
self.cst1.refresh_from_db()
|
self.cst1.refresh_from_db()
|
||||||
self.assertEqual(response.data['convention'], 'tt')
|
|
||||||
self.assertEqual(self.cst1.convention, 'tt')
|
self.assertEqual(self.cst1.convention, 'tt')
|
||||||
|
|
||||||
self.executeOK(data=data, schema=self.rsform_owned.model.pk)
|
self.executeOK(data=data, schema=self.owned_id)
|
||||||
|
|
||||||
|
|
||||||
|
@decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch')
|
||||||
|
def test_partial_update_rename(self):
|
||||||
|
data = {'target': self.cst1.pk, 'item_data': {'alias': self.cst3.alias}}
|
||||||
|
self.executeBadData(data=data, schema=self.owned_id)
|
||||||
|
|
||||||
|
d1 = self.owned.insert_new(
|
||||||
|
alias='D1',
|
||||||
|
term_raw='@{X1|plur}',
|
||||||
|
definition_formal='X1'
|
||||||
|
)
|
||||||
|
self.assertEqual(self.cst1.order, 0)
|
||||||
|
self.assertEqual(self.cst1.alias, 'X1')
|
||||||
|
self.assertEqual(self.cst1.cst_type, CstType.BASE)
|
||||||
|
|
||||||
|
data = {'target': self.cst1.pk, 'item_data': {'alias': 'D2', 'cst_type': CstType.TERM}}
|
||||||
|
self.executeOK(data=data, schema=self.owned_id)
|
||||||
|
d1.refresh_from_db()
|
||||||
|
self.cst1.refresh_from_db()
|
||||||
|
self.assertEqual(d1.term_resolved, '')
|
||||||
|
self.assertEqual(d1.term_raw, '@{D2|plur}')
|
||||||
|
self.assertEqual(self.cst1.order, 0)
|
||||||
|
self.assertEqual(self.cst1.alias, 'D2')
|
||||||
|
self.assertEqual(self.cst1.cst_type, CstType.TERM)
|
||||||
|
|
||||||
|
|
||||||
@decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch')
|
@decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch')
|
||||||
|
@ -564,11 +540,9 @@ class TestConstituentaAPI(EndpointTester):
|
||||||
'definition_raw': 'New def'
|
'definition_raw': 'New def'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
response = self.executeOK(data=data, schema=self.rsform_owned.model.pk)
|
self.executeOK(data=data, schema=self.owned_id)
|
||||||
self.cst3.refresh_from_db()
|
self.cst3.refresh_from_db()
|
||||||
self.assertEqual(response.data['term_resolved'], 'New term')
|
|
||||||
self.assertEqual(self.cst3.term_resolved, 'New term')
|
self.assertEqual(self.cst3.term_resolved, 'New term')
|
||||||
self.assertEqual(response.data['definition_resolved'], 'New def')
|
|
||||||
self.assertEqual(self.cst3.definition_resolved, 'New def')
|
self.assertEqual(self.cst3.definition_resolved, 'New def')
|
||||||
|
|
||||||
|
|
||||||
|
@ -581,12 +555,10 @@ class TestConstituentaAPI(EndpointTester):
|
||||||
'definition_raw': '@{X1|nomn,sing} @{X1|sing,datv}'
|
'definition_raw': '@{X1|nomn,sing} @{X1|sing,datv}'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
response = self.executeOK(data=data, schema=self.rsform_owned.model.pk)
|
self.executeOK(data=data, schema=self.owned_id)
|
||||||
self.cst3.refresh_from_db()
|
self.cst3.refresh_from_db()
|
||||||
self.assertEqual(self.cst3.term_resolved, self.cst1.term_resolved)
|
self.assertEqual(self.cst3.term_resolved, self.cst1.term_resolved)
|
||||||
self.assertEqual(response.data['term_resolved'], self.cst1.term_resolved)
|
|
||||||
self.assertEqual(self.cst3.definition_resolved, f'{self.cst1.term_resolved} form1')
|
self.assertEqual(self.cst3.definition_resolved, f'{self.cst1.term_resolved} form1')
|
||||||
self.assertEqual(response.data['definition_resolved'], f'{self.cst1.term_resolved} form1')
|
|
||||||
|
|
||||||
@decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch')
|
@decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch')
|
||||||
def test_update_term_forms(self):
|
def test_update_term_forms(self):
|
||||||
|
@ -597,25 +569,10 @@ class TestConstituentaAPI(EndpointTester):
|
||||||
'term_forms': [{'text': 'form1', 'tags': 'sing,datv'}]
|
'term_forms': [{'text': 'form1', 'tags': 'sing,datv'}]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
response = self.executeOK(data=data, schema=self.rsform_owned.model.pk)
|
self.executeOK(data=data, schema=self.owned_id)
|
||||||
self.cst3.refresh_from_db()
|
self.cst3.refresh_from_db()
|
||||||
self.assertEqual(self.cst3.definition_resolved, 'form1')
|
self.assertEqual(self.cst3.definition_resolved, 'form1')
|
||||||
self.assertEqual(response.data['definition_resolved'], 'form1')
|
|
||||||
self.assertEqual(self.cst3.term_forms, data['item_data']['term_forms'])
|
self.assertEqual(self.cst3.term_forms, data['item_data']['term_forms'])
|
||||||
self.assertEqual(response.data['term_forms'], data['item_data']['term_forms'])
|
|
||||||
|
|
||||||
|
|
||||||
@decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch')
|
|
||||||
def test_readonly_cst_fields(self):
|
|
||||||
data = {
|
|
||||||
'target': self.cst1.pk,
|
|
||||||
'item_data': {
|
|
||||||
'alias': 'X33'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
response = self.executeOK(data=data, schema=self.rsform_owned.model.pk)
|
|
||||||
self.assertEqual(response.data['alias'], 'X1')
|
|
||||||
self.assertEqual(response.data['alias'], self.cst1.alias)
|
|
||||||
|
|
||||||
|
|
||||||
class TestInlineSynthesis(EndpointTester):
|
class TestInlineSynthesis(EndpointTester):
|
||||||
|
|
|
@ -41,7 +41,6 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
|
||||||
if self.action in [
|
if self.action in [
|
||||||
'load_trs',
|
'load_trs',
|
||||||
'create_cst',
|
'create_cst',
|
||||||
'rename_cst',
|
|
||||||
'update_cst',
|
'update_cst',
|
||||||
'move_cst',
|
'move_cst',
|
||||||
'delete_multiple_cst',
|
'delete_multiple_cst',
|
||||||
|
@ -102,7 +101,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
|
||||||
tags=['RSForm'],
|
tags=['RSForm'],
|
||||||
request=s.CstUpdateSerializer,
|
request=s.CstUpdateSerializer,
|
||||||
responses={
|
responses={
|
||||||
c.HTTP_200_OK: s.CstInfoSerializer,
|
c.HTTP_200_OK: s.RSFormParseSerializer,
|
||||||
c.HTTP_400_BAD_REQUEST: 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
|
||||||
|
@ -120,9 +119,22 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
old_data = schema.update_cst(cst, data)
|
old_data = schema.update_cst(cst, data)
|
||||||
PropagationFacade.after_update_cst(schema, cst, data, old_data)
|
PropagationFacade.after_update_cst(schema, cst, data, old_data)
|
||||||
|
if 'alias' in data and data['alias'] != cst.alias:
|
||||||
|
cst.refresh_from_db()
|
||||||
|
changed_type = 'cst_type' in data and cst.cst_type != data['cst_type']
|
||||||
|
mapping = {cst.alias: data['alias']}
|
||||||
|
cst.alias = data['alias']
|
||||||
|
if changed_type:
|
||||||
|
cst.cst_type = data['cst_type']
|
||||||
|
cst.save()
|
||||||
|
schema.apply_mapping(mapping=mapping, change_aliases=False)
|
||||||
|
schema.save()
|
||||||
|
cst.refresh_from_db()
|
||||||
|
if changed_type:
|
||||||
|
PropagationFacade.after_change_cst_type(schema, cst)
|
||||||
return Response(
|
return Response(
|
||||||
status=c.HTTP_200_OK,
|
status=c.HTTP_200_OK,
|
||||||
data=s.CstInfoSerializer(m.Constituenta.objects.get(pk=request.data['target'])).data
|
data=s.RSFormParseSerializer(schema.model).data
|
||||||
)
|
)
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
|
@ -169,43 +181,6 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@extend_schema(
|
|
||||||
summary='rename constituenta',
|
|
||||||
tags=['Constituenta'],
|
|
||||||
request=s.CstRenameSerializer,
|
|
||||||
responses={
|
|
||||||
c.HTTP_200_OK: s.NewCstResponse,
|
|
||||||
c.HTTP_400_BAD_REQUEST: None,
|
|
||||||
c.HTTP_403_FORBIDDEN: None,
|
|
||||||
c.HTTP_404_NOT_FOUND: None
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@action(detail=True, methods=['patch'], url_path='rename-cst')
|
|
||||||
def rename_cst(self, request: Request, pk) -> HttpResponse:
|
|
||||||
''' Rename constituenta possibly changing type. '''
|
|
||||||
model = self._get_item()
|
|
||||||
serializer = s.CstRenameSerializer(data=request.data, context={'schema': model})
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
cst = cast(m.Constituenta, serializer.validated_data['target'])
|
|
||||||
changed_type = cst.cst_type != serializer.validated_data['cst_type']
|
|
||||||
mapping = {cst.alias: serializer.validated_data['alias']}
|
|
||||||
schema = m.RSForm(model)
|
|
||||||
with transaction.atomic():
|
|
||||||
cst.alias = serializer.validated_data['alias']
|
|
||||||
cst.cst_type = serializer.validated_data['cst_type']
|
|
||||||
cst.save()
|
|
||||||
schema.apply_mapping(mapping=mapping, change_aliases=False)
|
|
||||||
schema.save()
|
|
||||||
cst.refresh_from_db()
|
|
||||||
if changed_type:
|
|
||||||
PropagationFacade.after_change_cst_type(schema, cst)
|
|
||||||
return Response(
|
|
||||||
status=c.HTTP_200_OK,
|
|
||||||
data={
|
|
||||||
'new_cst': s.CstInfoSerializer(cst).data,
|
|
||||||
'schema': s.RSFormParseSerializer(schema.model).data
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
summary='execute substitutions',
|
summary='execute substitutions',
|
||||||
|
|
|
@ -5,18 +5,19 @@ from rest_framework import serializers
|
||||||
|
|
||||||
from apps.library.models import Editor
|
from apps.library.models import Editor
|
||||||
from shared import messages as msg
|
from shared import messages as msg
|
||||||
|
from shared.serializers import StrictModelSerializer, StrictSerializer
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
class NonFieldErrorSerializer(serializers.Serializer):
|
class NonFieldErrorSerializer(StrictSerializer):
|
||||||
''' Serializer: list of non-field errors. '''
|
''' Serializer: list of non-field errors. '''
|
||||||
non_field_errors = serializers.ListField(
|
non_field_errors = serializers.ListField(
|
||||||
child=serializers.CharField()
|
child=serializers.CharField()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class LoginSerializer(serializers.Serializer):
|
class LoginSerializer(StrictSerializer):
|
||||||
''' Serializer: User authentication by login/password. '''
|
''' Serializer: User authentication by login/password. '''
|
||||||
username = serializers.CharField(
|
username = serializers.CharField(
|
||||||
label='Имя пользователя',
|
label='Имя пользователя',
|
||||||
|
@ -54,7 +55,7 @@ class LoginSerializer(serializers.Serializer):
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class AuthSerializer(serializers.Serializer):
|
class AuthSerializer(StrictSerializer):
|
||||||
''' Serializer: Authorization data. '''
|
''' Serializer: Authorization data. '''
|
||||||
id = serializers.IntegerField()
|
id = serializers.IntegerField()
|
||||||
username = serializers.CharField()
|
username = serializers.CharField()
|
||||||
|
@ -77,7 +78,7 @@ class AuthSerializer(serializers.Serializer):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
class UserSerializer(StrictModelSerializer):
|
||||||
''' Serializer: User data. '''
|
''' Serializer: User data. '''
|
||||||
id = serializers.IntegerField(read_only=True)
|
id = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
|
@ -105,7 +106,7 @@ class UserSerializer(serializers.ModelSerializer):
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class UserInfoSerializer(serializers.ModelSerializer):
|
class UserInfoSerializer(StrictModelSerializer):
|
||||||
''' Serializer: User open information. '''
|
''' Serializer: User open information. '''
|
||||||
id = serializers.IntegerField(read_only=True)
|
id = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
|
@ -119,13 +120,13 @@ class UserInfoSerializer(serializers.ModelSerializer):
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class ChangePasswordSerializer(serializers.Serializer):
|
class ChangePasswordSerializer(StrictSerializer):
|
||||||
''' Serializer: Change password. '''
|
''' Serializer: Change password. '''
|
||||||
old_password = serializers.CharField(required=True)
|
old_password = serializers.CharField(required=True)
|
||||||
new_password = serializers.CharField(required=True)
|
new_password = serializers.CharField(required=True)
|
||||||
|
|
||||||
|
|
||||||
class SignupSerializer(serializers.ModelSerializer):
|
class SignupSerializer(StrictModelSerializer):
|
||||||
''' Serializer: Create user profile. '''
|
''' Serializer: Create user profile. '''
|
||||||
id = serializers.IntegerField(read_only=True)
|
id = serializers.IntegerField(read_only=True)
|
||||||
password = serializers.CharField(write_only=True, required=True, validators=[validate_password])
|
password = serializers.CharField(write_only=True, required=True, validators=[validate_password])
|
||||||
|
|
|
@ -76,6 +76,7 @@ INSTALLED_APPS = [
|
||||||
'apps.library',
|
'apps.library',
|
||||||
'apps.rsform',
|
'apps.rsform',
|
||||||
'apps.oss',
|
'apps.oss',
|
||||||
|
'apps.prompt',
|
||||||
|
|
||||||
'drf_spectacular',
|
'drf_spectacular',
|
||||||
'drf_spectacular_sidecar',
|
'drf_spectacular_sidecar',
|
||||||
|
|
|
@ -11,6 +11,7 @@ urlpatterns = [
|
||||||
path('api/', include('apps.library.urls')),
|
path('api/', include('apps.library.urls')),
|
||||||
path('api/', include('apps.rsform.urls')),
|
path('api/', include('apps.rsform.urls')),
|
||||||
path('api/', include('apps.oss.urls')),
|
path('api/', include('apps.oss.urls')),
|
||||||
|
path('api/', include('apps.prompt.urls')),
|
||||||
path('users/', include('apps.users.urls')),
|
path('users/', include('apps.users.urls')),
|
||||||
path('schema', SpectacularAPIView.as_view(), name='schema'),
|
path('schema', SpectacularAPIView.as_view(), name='schema'),
|
||||||
path('redoc', SpectacularRedocView.as_view()),
|
path('redoc', SpectacularRedocView.as_view()),
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
tzdata==2025.2
|
tzdata==2025.2
|
||||||
Django==5.2.1
|
Django==5.2.4
|
||||||
djangorestframework==3.16.0
|
djangorestframework==3.16.0
|
||||||
django-cors-headers==4.7.0
|
django-cors-headers==4.7.0
|
||||||
django-filter==25.1
|
django-filter==25.1
|
||||||
drf-spectacular==0.28.0
|
drf-spectacular==0.28.0
|
||||||
drf-spectacular-sidecar==2025.5.1
|
drf-spectacular-sidecar==2025.7.1
|
||||||
coreapi==2.3.3
|
coreapi==2.3.3
|
||||||
django-rest-passwordreset==1.5.0
|
django-rest-passwordreset==1.5.0
|
||||||
cctext==0.1.4
|
cctext==0.1.4
|
||||||
|
@ -15,7 +15,7 @@ gunicorn==23.0.0
|
||||||
|
|
||||||
djangorestframework-stubs==3.16.0
|
djangorestframework-stubs==3.16.0
|
||||||
django-extensions==4.1
|
django-extensions==4.1
|
||||||
django-stubs==5.2.0
|
django-stubs==5.2.1
|
||||||
mypy==1.15.0
|
mypy==1.15.0
|
||||||
pylint==3.3.7
|
pylint==3.3.7
|
||||||
coverage==7.8.2
|
coverage==7.9.2
|
|
@ -1,10 +1,10 @@
|
||||||
tzdata==2025.2
|
tzdata==2025.2
|
||||||
Django==5.2.1
|
Django==5.2.4
|
||||||
djangorestframework==3.16.0
|
djangorestframework==3.16.0
|
||||||
django-cors-headers==4.7.0
|
django-cors-headers==4.7.0
|
||||||
django-filter==25.1
|
django-filter==25.1
|
||||||
drf-spectacular==0.28.0
|
drf-spectacular==0.28.0
|
||||||
drf-spectacular-sidecar==2025.5.1
|
drf-spectacular-sidecar==2025.7.1
|
||||||
coreapi==2.3.3
|
coreapi==2.3.3
|
||||||
django-rest-passwordreset==1.5.0
|
django-rest-passwordreset==1.5.0
|
||||||
cctext==0.1.4
|
cctext==0.1.4
|
||||||
|
|
|
@ -2,6 +2,14 @@
|
||||||
# pylint: skip-file
|
# pylint: skip-file
|
||||||
|
|
||||||
|
|
||||||
|
def fieldNotAllowed():
|
||||||
|
return 'Недопустимое поле'
|
||||||
|
|
||||||
|
|
||||||
|
def constituentsInvalid(constituents: list[int]):
|
||||||
|
return f'некорректные конституенты для схемы: {constituents}'
|
||||||
|
|
||||||
|
|
||||||
def constituentaNotInRSform(title: str):
|
def constituentaNotInRSform(title: str):
|
||||||
return f'Конституента не принадлежит схеме: {title}'
|
return f'Конституента не принадлежит схеме: {title}'
|
||||||
|
|
||||||
|
@ -144,3 +152,19 @@ def passwordsNotMatch():
|
||||||
|
|
||||||
def emailAlreadyTaken():
|
def emailAlreadyTaken():
|
||||||
return 'Пользователь с данным email уже существует'
|
return 'Пользователь с данным email уже существует'
|
||||||
|
|
||||||
|
|
||||||
|
def promptLabelTaken(label: str):
|
||||||
|
return f'Шаблон с меткой "{label}" уже существует у пользователя.'
|
||||||
|
|
||||||
|
|
||||||
|
def promptNotOwner():
|
||||||
|
return 'Вы не являетесь владельцем этого шаблона.'
|
||||||
|
|
||||||
|
|
||||||
|
def promptSharedPermissionDenied():
|
||||||
|
return 'Только администратор может сделать шаблон общедоступным.'
|
||||||
|
|
||||||
|
|
||||||
|
def promptNotFound():
|
||||||
|
return 'Шаблон не найден.'
|
||||||
|
|
23
rsconcept/backend/shared/serializers.py
Normal file
23
rsconcept/backend/shared/serializers.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
import shared.messages as msg
|
||||||
|
|
||||||
|
|
||||||
|
class StrictSerializer(serializers.Serializer):
|
||||||
|
def to_internal_value(self, data):
|
||||||
|
extra_keys = set(data.keys()) - set(self.fields.keys())
|
||||||
|
if extra_keys:
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
key: msg.fieldNotAllowed() for key in extra_keys
|
||||||
|
})
|
||||||
|
return super().to_internal_value(data)
|
||||||
|
|
||||||
|
|
||||||
|
class StrictModelSerializer(serializers.ModelSerializer):
|
||||||
|
def to_internal_value(self, data):
|
||||||
|
extra_keys = set(data.keys()) - set(self.fields.keys())
|
||||||
|
if extra_keys:
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
key: msg.fieldNotAllowed() for key in extra_keys
|
||||||
|
})
|
||||||
|
return super().to_internal_value(data)
|
1207
rsconcept/frontend/package-lock.json
generated
1207
rsconcept/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
@ -22,8 +22,8 @@
|
||||||
"@tanstack/react-query": "^5.81.5",
|
"@tanstack/react-query": "^5.81.5",
|
||||||
"@tanstack/react-query-devtools": "^5.81.5",
|
"@tanstack/react-query-devtools": "^5.81.5",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@uiw/codemirror-themes": "^4.23.14",
|
"@uiw/codemirror-themes": "^4.24.0",
|
||||||
"@uiw/react-codemirror": "^4.23.14",
|
"@uiw/react-codemirror": "^4.24.0",
|
||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
@ -35,20 +35,20 @@
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-error-boundary": "^6.0.0",
|
"react-error-boundary": "^6.0.0",
|
||||||
"react-hook-form": "^7.59.0",
|
"react-hook-form": "^7.60.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-intl": "^7.1.11",
|
"react-intl": "^7.1.11",
|
||||||
"react-router": "^7.6.3",
|
"react-router": "^7.6.3",
|
||||||
"react-scan": "^0.3.6",
|
"react-scan": "^0.4.3",
|
||||||
"react-tabs": "^6.1.0",
|
"react-tabs": "^6.1.0",
|
||||||
"react-toastify": "^11.0.5",
|
"react-toastify": "^11.0.5",
|
||||||
"react-tooltip": "^5.29.1",
|
"react-tooltip": "^5.29.1",
|
||||||
"react-zoom-pan-pinch": "^3.7.0",
|
"react-zoom-pan-pinch": "^3.7.0",
|
||||||
"reactflow": "^11.11.4",
|
"reactflow": "^11.11.4",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tw-animate-css": "^1.3.4",
|
"tw-animate-css": "^1.3.5",
|
||||||
"use-debounce": "^10.0.5",
|
"use-debounce": "^10.0.5",
|
||||||
"zod": "^3.25.67",
|
"zod": "^3.25.76",
|
||||||
"zustand": "^5.0.6"
|
"zustand": "^5.0.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -56,14 +56,14 @@
|
||||||
"@playwright/test": "^1.53.2",
|
"@playwright/test": "^1.53.2",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^24.0.8",
|
"@types/node": "^24.0.10",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.0.1",
|
"@typescript-eslint/eslint-plugin": "^8.0.1",
|
||||||
"@typescript-eslint/parser": "^8.0.1",
|
"@typescript-eslint/parser": "^8.0.1",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
"babel-plugin-react-compiler": "^19.1.0-rc.1",
|
"babel-plugin-react-compiler": "^19.1.0-rc.1",
|
||||||
"eslint": "^9.30.0",
|
"eslint": "^9.30.1",
|
||||||
"eslint-plugin-import": "^2.32.0",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
"eslint-plugin-playwright": "^2.2.0",
|
"eslint-plugin-playwright": "^2.2.0",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
@ -71,16 +71,16 @@
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
"jest": "^30.0.3",
|
"jest": "^30.0.4",
|
||||||
"stylelint": "^16.21.0",
|
"stylelint": "^16.21.1",
|
||||||
"stylelint-config-recommended": "^16.0.0",
|
"stylelint-config-recommended": "^16.0.0",
|
||||||
"stylelint-config-standard": "^38.0.0",
|
"stylelint-config-standard": "^38.0.0",
|
||||||
"stylelint-config-tailwindcss": "^1.0.0",
|
"stylelint-config-tailwindcss": "^1.0.0",
|
||||||
"tailwindcss": "^4.0.7",
|
"tailwindcss": "^4.0.7",
|
||||||
"ts-jest": "^29.4.0",
|
"ts-jest": "^29.4.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"typescript-eslint": "^8.35.1",
|
"typescript-eslint": "^8.36.0",
|
||||||
"vite": "^7.0.0"
|
"vite": "^7.0.3"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"preset": "ts-jest",
|
"preset": "ts-jest",
|
||||||
|
|
|
@ -27,7 +27,7 @@ export function ApplicationLayout() {
|
||||||
<NavigationState>
|
<NavigationState>
|
||||||
<div className='min-w-80 antialiased h-full max-w-480 mx-auto'>
|
<div className='min-w-80 antialiased h-full max-w-480 mx-auto'>
|
||||||
<ToasterThemed
|
<ToasterThemed
|
||||||
className={clsx('sm:text-[14px]/[20px] text-[12px]/[16px]', noNavigationAnimation ? 'mt-6' : 'mt-14')}
|
className={clsx('sm:text-[14px]/[20px] text-[12px]/[16px]', noNavigationAnimation ? 'mt-9' : 'mt-17')}
|
||||||
aria-label='Оповещения'
|
aria-label='Оповещения'
|
||||||
autoClose={3000}
|
autoClose={3000}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
|
|
|
@ -20,9 +20,9 @@ const DlgCloneLibraryItem = React.lazy(() =>
|
||||||
const DlgCreateCst = React.lazy(() =>
|
const DlgCreateCst = React.lazy(() =>
|
||||||
import('@/features/rsform/dialogs/dlg-create-cst').then(module => ({ default: module.DlgCreateCst }))
|
import('@/features/rsform/dialogs/dlg-create-cst').then(module => ({ default: module.DlgCreateCst }))
|
||||||
);
|
);
|
||||||
const DlgCreateOperation = React.lazy(() =>
|
const DlgCreateSynthesis = React.lazy(() =>
|
||||||
import('@/features/oss/dialogs/dlg-create-operation').then(module => ({
|
import('@/features/oss/dialogs/dlg-create-synthesis').then(module => ({
|
||||||
default: module.DlgCreateOperation
|
default: module.DlgCreateSynthesis
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const DlgCreateVersion = React.lazy(() =>
|
const DlgCreateVersion = React.lazy(() =>
|
||||||
|
@ -128,6 +128,26 @@ const DlgOssSettings = React.lazy(() =>
|
||||||
default: module.DlgOssSettings
|
default: module.DlgOssSettings
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
const DlgEditCst = React.lazy(() =>
|
||||||
|
import('@/features/rsform/dialogs/dlg-edit-cst').then(module => ({ default: module.DlgEditCst }))
|
||||||
|
);
|
||||||
|
const DlgShowTermGraph = React.lazy(() =>
|
||||||
|
import('@/features/oss/dialogs/dlg-show-term-graph').then(module => ({ default: module.DlgShowTermGraph }))
|
||||||
|
);
|
||||||
|
const DlgCreateSchema = React.lazy(() =>
|
||||||
|
import('@/features/oss/dialogs/dlg-create-schema').then(module => ({ default: module.DlgCreateSchema }))
|
||||||
|
);
|
||||||
|
const DlgImportSchema = React.lazy(() =>
|
||||||
|
import('@/features/oss/dialogs/dlg-import-schema').then(module => ({ default: module.DlgImportSchema }))
|
||||||
|
);
|
||||||
|
const DlgAIPromptDialog = React.lazy(() =>
|
||||||
|
import('@/features/ai/dialogs/dlg-ai-prompt').then(module => ({ default: module.DlgAIPromptDialog }))
|
||||||
|
);
|
||||||
|
const DlgCreatePromptTemplate = React.lazy(() =>
|
||||||
|
import('@/features/ai/dialogs/dlg-create-prompt-template').then(module => ({
|
||||||
|
default: module.DlgCreatePromptTemplate
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
export const GlobalDialogs = () => {
|
export const GlobalDialogs = () => {
|
||||||
const active = useDialogsStore(state => state.active);
|
const active = useDialogsStore(state => state.active);
|
||||||
|
@ -140,8 +160,8 @@ export const GlobalDialogs = () => {
|
||||||
return <DlgCstTemplate />;
|
return <DlgCstTemplate />;
|
||||||
case DialogType.CREATE_CONSTITUENTA:
|
case DialogType.CREATE_CONSTITUENTA:
|
||||||
return <DlgCreateCst />;
|
return <DlgCreateCst />;
|
||||||
case DialogType.CREATE_OPERATION:
|
case DialogType.CREATE_SYNTHESIS:
|
||||||
return <DlgCreateOperation />;
|
return <DlgCreateSynthesis />;
|
||||||
case DialogType.CREATE_BLOCK:
|
case DialogType.CREATE_BLOCK:
|
||||||
return <DlgCreateBlock />;
|
return <DlgCreateBlock />;
|
||||||
case DialogType.EDIT_BLOCK:
|
case DialogType.EDIT_BLOCK:
|
||||||
|
@ -188,5 +208,17 @@ export const GlobalDialogs = () => {
|
||||||
return <DlgSubstituteCst />;
|
return <DlgSubstituteCst />;
|
||||||
case DialogType.UPLOAD_RSFORM:
|
case DialogType.UPLOAD_RSFORM:
|
||||||
return <DlgUploadRSForm />;
|
return <DlgUploadRSForm />;
|
||||||
|
case DialogType.EDIT_CONSTITUENTA:
|
||||||
|
return <DlgEditCst />;
|
||||||
|
case DialogType.SHOW_TERM_GRAPH:
|
||||||
|
return <DlgShowTermGraph />;
|
||||||
|
case DialogType.CREATE_SCHEMA:
|
||||||
|
return <DlgCreateSchema />;
|
||||||
|
case DialogType.IMPORT_SCHEMA:
|
||||||
|
return <DlgImportSchema />;
|
||||||
|
case DialogType.AI_PROMPT:
|
||||||
|
return <DlgAIPromptDialog />;
|
||||||
|
case DialogType.CREATE_PROMPT_TEMPLATE:
|
||||||
|
return <DlgCreatePromptTemplate />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
59
rsconcept/frontend/src/app/navigation/menu-ai.tsx
Normal file
59
rsconcept/frontend/src/app/navigation/menu-ai.tsx
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
import { useAuth } from '@/features/auth/backend/use-auth';
|
||||||
|
|
||||||
|
import { Dropdown, DropdownButton, useDropdown } from '@/components/dropdown';
|
||||||
|
import { IconAssistant, IconChat, IconTemplates } from '@/components/icons';
|
||||||
|
import { useDialogsStore } from '@/stores/dialogs';
|
||||||
|
import { globalIDs } from '@/utils/constants';
|
||||||
|
|
||||||
|
import { urls } from '../urls';
|
||||||
|
|
||||||
|
import { NavigationButton } from './navigation-button';
|
||||||
|
import { useConceptNavigation } from './navigation-context';
|
||||||
|
|
||||||
|
export function MenuAI() {
|
||||||
|
const router = useConceptNavigation();
|
||||||
|
const menu = useDropdown();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const showAIPrompt = useDialogsStore(state => state.showAIPrompt);
|
||||||
|
|
||||||
|
function navigateTemplates(event: React.MouseEvent<Element>) {
|
||||||
|
menu.hide();
|
||||||
|
router.push({ path: urls.prompt_templates, newTab: event.ctrlKey || event.metaKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreatePrompt(event: React.MouseEvent<Element>) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
menu.hide();
|
||||||
|
showAIPrompt();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={menu.ref} onBlur={menu.handleBlur} className='flex items-center justify-start relative h-full'>
|
||||||
|
<NavigationButton
|
||||||
|
title='ИИ помощник' //
|
||||||
|
hideTitle={menu.isOpen}
|
||||||
|
aria-expanded={menu.isOpen}
|
||||||
|
aria-controls={globalIDs.ai_dropdown}
|
||||||
|
icon={<IconAssistant size='1.5rem' />}
|
||||||
|
onClick={menu.toggle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dropdown id={globalIDs.ai_dropdown} className='min-w-[12ch] max-w-48' stretchLeft isOpen={menu.isOpen}>
|
||||||
|
<DropdownButton
|
||||||
|
text='Запрос'
|
||||||
|
title='Создать запрос'
|
||||||
|
icon={<IconChat size='1rem' />}
|
||||||
|
onClick={handleCreatePrompt}
|
||||||
|
/>
|
||||||
|
<DropdownButton
|
||||||
|
text='Шаблоны'
|
||||||
|
title={user?.is_staff ? 'Шаблоны запросов' : 'Доступно только зарегистрированным пользователям'}
|
||||||
|
icon={<IconTemplates size='1rem' />}
|
||||||
|
onClick={navigateTemplates}
|
||||||
|
disabled={!user?.is_staff}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -9,11 +9,11 @@ import { useConceptNavigation } from './navigation-context';
|
||||||
import { UserButton } from './user-button';
|
import { UserButton } from './user-button';
|
||||||
import { UserDropdown } from './user-dropdown';
|
import { UserDropdown } from './user-dropdown';
|
||||||
|
|
||||||
export function UserMenu() {
|
export function MenuUser() {
|
||||||
const router = useConceptNavigation();
|
const router = useConceptNavigation();
|
||||||
const menu = useDropdown();
|
const menu = useDropdown();
|
||||||
return (
|
return (
|
||||||
<div ref={menu.ref} onBlur={menu.handleBlur} className='flex items-center justify-start relative h-full pr-2'>
|
<div ref={menu.ref} onBlur={menu.handleBlur} className='flex items-center justify-start relative h-full'>
|
||||||
<Suspense fallback={<Loader circular scale={1.5} />}>
|
<Suspense fallback={<Loader circular scale={1.5} />}>
|
||||||
<UserButton
|
<UserButton
|
||||||
onLogin={() => router.push({ path: urls.login, force: true })}
|
onLogin={() => router.push({ path: urls.login, force: true })}
|
|
@ -24,7 +24,7 @@ export function NavigationButton({ icon, title, hideTitle, className, style, onC
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
{icon ? icon : null}
|
{icon ? icon : null}
|
||||||
{text ? <span className='hidden sm:inline'>{text}</span> : null}
|
{text ? <span className='hidden md:inline'>{text}</span> : null}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,10 +8,11 @@ import { useDialogsStore } from '@/stores/dialogs';
|
||||||
import { urls } from '../urls';
|
import { urls } from '../urls';
|
||||||
|
|
||||||
import { Logo } from './logo';
|
import { Logo } from './logo';
|
||||||
|
import { MenuAI } from './menu-ai';
|
||||||
|
import { MenuUser } from './menu-user';
|
||||||
import { NavigationButton } from './navigation-button';
|
import { NavigationButton } from './navigation-button';
|
||||||
import { useConceptNavigation } from './navigation-context';
|
import { useConceptNavigation } from './navigation-context';
|
||||||
import { ToggleNavigation } from './toggle-navigation';
|
import { ToggleNavigation } from './toggle-navigation';
|
||||||
import { UserMenu } from './user-menu';
|
|
||||||
|
|
||||||
export function Navigation() {
|
export function Navigation() {
|
||||||
const { push } = useConceptNavigation();
|
const { push } = useConceptNavigation();
|
||||||
|
@ -41,11 +42,13 @@ export function Navigation() {
|
||||||
<div className='flex items-center mr-auto cursor-pointer' onClick={!size.isSmall ? navigateHome : undefined}>
|
<div className='flex items-center mr-auto cursor-pointer' onClick={!size.isSmall ? navigateHome : undefined}>
|
||||||
<Logo />
|
<Logo />
|
||||||
</div>
|
</div>
|
||||||
<div className='flex gap-2 items-center'>
|
<div className='flex gap-2 items-center pr-2'>
|
||||||
<NavigationButton text='Новая схема' icon={<IconNewItem2 size='1.5rem' />} onClick={navigateCreateNew} />
|
<NavigationButton text='Новая схема' icon={<IconNewItem2 size='1.5rem' />} onClick={navigateCreateNew} />
|
||||||
<NavigationButton text='Библиотека' icon={<IconLibrary2 size='1.5rem' />} onClick={navigateLibrary} />
|
<NavigationButton text='Библиотека' icon={<IconLibrary2 size='1.5rem' />} onClick={navigateLibrary} />
|
||||||
<NavigationButton text='Справка' icon={<IconManuals size='1.5rem' />} onClick={navigateHelp} />
|
<NavigationButton text='Справка' icon={<IconManuals size='1.5rem' />} onClick={navigateHelp} />
|
||||||
<UserMenu />
|
|
||||||
|
<MenuAI />
|
||||||
|
<MenuUser />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { createBrowserRouter } from 'react-router';
|
import { createBrowserRouter } from 'react-router';
|
||||||
|
|
||||||
|
import { prefetchAvailableTemplates } from '@/features/ai/backend/use-available-templates';
|
||||||
import { prefetchAuth } from '@/features/auth/backend/use-auth';
|
import { prefetchAuth } from '@/features/auth/backend/use-auth';
|
||||||
import { LoginPage } from '@/features/auth/pages/login-page';
|
import { LoginPage } from '@/features/auth/pages/login-page';
|
||||||
import { HomePage } from '@/features/home/home-page';
|
import { HomePage } from '@/features/home/home-page';
|
||||||
|
@ -84,6 +85,15 @@ export const Router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
path: `${routes.database_schema}`,
|
path: `${routes.database_schema}`,
|
||||||
lazy: () => import('@/features/home/database-schema-page')
|
lazy: () => import('@/features/home/database-schema-page')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: routes.prompt_templates,
|
||||||
|
loader: prefetchAvailableTemplates,
|
||||||
|
lazy: () => import('@/features/ai/pages/prompt-templates-page')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '*',
|
||||||
|
element: <NotFoundPage />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,8 @@ export const routes = {
|
||||||
rsforms: 'rsforms',
|
rsforms: 'rsforms',
|
||||||
oss: 'oss',
|
oss: 'oss',
|
||||||
icons: 'icons',
|
icons: 'icons',
|
||||||
database_schema: 'database-schema'
|
database_schema: 'database-schema',
|
||||||
|
prompt_templates: 'prompt-templates'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Internal navigation URLs. */
|
/** Internal navigation URLs. */
|
||||||
|
@ -37,6 +38,9 @@ export const urls = {
|
||||||
library: `/${routes.library}`,
|
library: `/${routes.library}`,
|
||||||
library_filter: (strategy: string) => `/library?filter=${strategy}`,
|
library_filter: (strategy: string) => `/library?filter=${strategy}`,
|
||||||
create_schema: `/${routes.create_schema}`,
|
create_schema: `/${routes.create_schema}`,
|
||||||
|
prompt_templates: `/${routes.prompt_templates}`,
|
||||||
|
prompt_template: (active: number | null, tab: number) =>
|
||||||
|
`/prompt-templates?tab=${tab}${active ? `&active=${active}` : ''}`,
|
||||||
manuals: `/${routes.manuals}`,
|
manuals: `/${routes.manuals}`,
|
||||||
help_topic: (topic: string) => `/manuals?topic=${topic}`,
|
help_topic: (topic: string) => `/manuals?topic=${topic}`,
|
||||||
schema: (id: number | string, version?: number | string) =>
|
schema: (id: number | string, version?: number | string) =>
|
||||||
|
|
|
@ -16,6 +16,7 @@ export const KEYS = {
|
||||||
library: 'library',
|
library: 'library',
|
||||||
users: 'users',
|
users: 'users',
|
||||||
cctext: 'cctext',
|
cctext: 'cctext',
|
||||||
|
prompts: 'prompts',
|
||||||
global_mutation: 'global_mutation',
|
global_mutation: 'global_mutation',
|
||||||
|
|
||||||
composite: {
|
composite: {
|
||||||
|
|
|
@ -37,7 +37,7 @@ export function DropdownButton({
|
||||||
'px-3 py-1 inline-flex items-center gap-2',
|
'px-3 py-1 inline-flex items-center gap-2',
|
||||||
'text-left text-sm text-ellipsis whitespace-nowrap',
|
'text-left text-sm text-ellipsis whitespace-nowrap',
|
||||||
'disabled:text-muted-foreground disabled:opacity-75',
|
'disabled:text-muted-foreground disabled:opacity-75',
|
||||||
'focus-outline cc-animate-background',
|
'focus-outline cc-animate-background select-none',
|
||||||
!!onClick ? 'cc-hover-bg cursor-pointer disabled:cursor-auto' : 'bg-secondary text-secondary-foreground',
|
!!onClick ? 'cc-hover-bg cursor-pointer disabled:cursor-auto' : 'bg-secondary text-secondary-foreground',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -5,6 +5,8 @@ import { Background, ReactFlow, type ReactFlowProps } from 'reactflow';
|
||||||
|
|
||||||
export { useReactFlow, useStoreApi } from 'reactflow';
|
export { useReactFlow, useStoreApi } from 'reactflow';
|
||||||
|
|
||||||
|
import { withPreventDefault } from '@/utils/utils';
|
||||||
|
|
||||||
import { cn } from '../utils';
|
import { cn } from '../utils';
|
||||||
|
|
||||||
type DiagramFlowProps = {
|
type DiagramFlowProps = {
|
||||||
|
@ -48,9 +50,7 @@ export function DiagramFlow({
|
||||||
|
|
||||||
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
|
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
|
||||||
if (event.code === 'Space') {
|
if (event.code === 'Space') {
|
||||||
event.preventDefault();
|
withPreventDefault(() => setSpaceMode(true))(event);
|
||||||
event.stopPropagation();
|
|
||||||
setSpaceMode(true);
|
|
||||||
}
|
}
|
||||||
onKeyDown?.(event);
|
onKeyDown?.(event);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
// 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
|
||||||
|
// Note: For Cursor hotkeys are Ctrl + M, Ctrl + Shift + S
|
||||||
|
|
||||||
/* eslint-disable simple-import-sort/exports */
|
/* eslint-disable simple-import-sort/exports */
|
||||||
// ==== General actions =======
|
// ==== General actions =======
|
||||||
|
@ -47,6 +48,7 @@ export { LuFolderOpen as IconFolderOpened } from 'react-icons/lu';
|
||||||
export { LuFolderClosed as IconFolderClosed } from 'react-icons/lu';
|
export { LuFolderClosed as IconFolderClosed } from 'react-icons/lu';
|
||||||
export { LuFolderDot as IconFolderEmpty } from 'react-icons/lu';
|
export { LuFolderDot as IconFolderEmpty } from 'react-icons/lu';
|
||||||
export { TbHelpOctagon as IconHelp } from 'react-icons/tb';
|
export { TbHelpOctagon as IconHelp } from 'react-icons/tb';
|
||||||
|
export { LuPresentation as IconSample } from 'react-icons/lu';
|
||||||
export { LuLightbulbOff as IconHelpOff } from 'react-icons/lu';
|
export { LuLightbulbOff as IconHelpOff } from 'react-icons/lu';
|
||||||
export { RiPushpinFill as IconPin } from 'react-icons/ri';
|
export { RiPushpinFill as IconPin } from 'react-icons/ri';
|
||||||
export { RiUnpinLine as IconUnpin } from 'react-icons/ri';
|
export { RiUnpinLine as IconUnpin } from 'react-icons/ri';
|
||||||
|
@ -62,6 +64,8 @@ export { PiFileCsv as IconCSV } from 'react-icons/pi';
|
||||||
|
|
||||||
// ==== User status =======
|
// ==== User status =======
|
||||||
export { LuCircleUserRound as IconUser } from 'react-icons/lu';
|
export { LuCircleUserRound as IconUser } from 'react-icons/lu';
|
||||||
|
export { FaUserAstronaut as IconAssistant } from 'react-icons/fa6';
|
||||||
|
export { IoChatbubblesOutline as IconChat } from 'react-icons/io5';
|
||||||
export { FaCircleUser as IconUser2 } from 'react-icons/fa6';
|
export { FaCircleUser as IconUser2 } from 'react-icons/fa6';
|
||||||
export { TbUserEdit as IconEditor } from 'react-icons/tb';
|
export { TbUserEdit as IconEditor } from 'react-icons/tb';
|
||||||
export { TbUserSearch as IconUserSearch } from 'react-icons/tb';
|
export { TbUserSearch as IconUserSearch } from 'react-icons/tb';
|
||||||
|
|
|
@ -102,7 +102,7 @@ export function ComboBox<Option>({
|
||||||
<IconRemove
|
<IconRemove
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
size='1rem'
|
size='1rem'
|
||||||
className='cc-remove absolute pointer-events-auto right-3'
|
className='cc-remove cc-hover-pulse absolute pointer-events-auto right-3'
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
@ -100,7 +100,7 @@ export function ComboMulti<Option>({
|
||||||
<IconRemove
|
<IconRemove
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
size='1rem'
|
size='1rem'
|
||||||
className='cc-remove'
|
className='cc-remove cc-hover-pulse'
|
||||||
onClick={event => {
|
onClick={event => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
handleRemoveValue(item);
|
handleRemoveValue(item);
|
||||||
|
|
70
rsconcept/frontend/src/features/ai/backend/api.ts
Normal file
70
rsconcept/frontend/src/features/ai/backend/api.ts
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
import { queryOptions } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { axiosDelete, axiosGet, axiosPatch, axiosPost } from '@/backend/api-transport';
|
||||||
|
import { DELAYS, KEYS } from '@/backend/configuration';
|
||||||
|
import { infoMsg } from '@/utils/labels';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type ICreatePromptTemplateDTO,
|
||||||
|
type IPromptTemplateDTO,
|
||||||
|
type IPromptTemplateListDTO,
|
||||||
|
type IUpdatePromptTemplateDTO,
|
||||||
|
schemaPromptTemplate,
|
||||||
|
schemaPromptTemplateList
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export const promptsApi = {
|
||||||
|
baseKey: KEYS.prompts,
|
||||||
|
|
||||||
|
getAvailableTemplatesQueryOptions: () =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: [KEYS.prompts, 'available'] as const,
|
||||||
|
staleTime: DELAYS.staleShort,
|
||||||
|
queryFn: meta =>
|
||||||
|
axiosGet<IPromptTemplateListDTO>({
|
||||||
|
schema: schemaPromptTemplateList,
|
||||||
|
endpoint: '/api/prompts/available/',
|
||||||
|
options: { signal: meta.signal }
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
getPromptTemplateQueryOptions: (id: number) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: [KEYS.prompts, id],
|
||||||
|
staleTime: DELAYS.staleShort,
|
||||||
|
queryFn: meta =>
|
||||||
|
axiosGet<IPromptTemplateDTO>({
|
||||||
|
schema: schemaPromptTemplate,
|
||||||
|
endpoint: `/api/prompts/${id}/`,
|
||||||
|
options: { signal: meta.signal }
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
createPromptTemplate: (data: ICreatePromptTemplateDTO) =>
|
||||||
|
axiosPost<ICreatePromptTemplateDTO, IPromptTemplateDTO>({
|
||||||
|
schema: schemaPromptTemplate,
|
||||||
|
endpoint: '/api/prompts/',
|
||||||
|
request: {
|
||||||
|
data: data,
|
||||||
|
successMessage: infoMsg.changesSaved
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
updatePromptTemplate: (id: number, data: IUpdatePromptTemplateDTO) =>
|
||||||
|
axiosPatch<IUpdatePromptTemplateDTO, IPromptTemplateDTO>({
|
||||||
|
schema: schemaPromptTemplate,
|
||||||
|
endpoint: `/api/prompts/${id}/`,
|
||||||
|
request: {
|
||||||
|
data: data,
|
||||||
|
successMessage: infoMsg.changesSaved
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
deletePromptTemplate: (id: number) =>
|
||||||
|
axiosDelete({
|
||||||
|
endpoint: `/api/prompts/${id}/`,
|
||||||
|
request: {
|
||||||
|
successMessage: infoMsg.changesSaved
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} as const;
|
59
rsconcept/frontend/src/features/ai/backend/types.ts
Normal file
59
rsconcept/frontend/src/features/ai/backend/types.ts
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { limits } from '@/utils/constants';
|
||||||
|
import { errorMsg } from '@/utils/labels';
|
||||||
|
|
||||||
|
/** Represents AI prompt. */
|
||||||
|
export type IPromptTemplate = IPromptTemplateDTO;
|
||||||
|
|
||||||
|
export type IPromptTemplateInfo = z.infer<typeof schemaPromptTemplateInfo>;
|
||||||
|
|
||||||
|
/** Full prompt template as returned by backend. */
|
||||||
|
export type IPromptTemplateDTO = z.infer<typeof schemaPromptTemplate>;
|
||||||
|
|
||||||
|
/** List item for available prompt templates (no text field). */
|
||||||
|
export type IPromptTemplateListDTO = z.infer<typeof schemaPromptTemplateList>;
|
||||||
|
|
||||||
|
/** Data for creating a prompt template. */
|
||||||
|
export type ICreatePromptTemplateDTO = z.infer<typeof schemaCreatePromptTemplate>;
|
||||||
|
|
||||||
|
/** Data for updating a prompt template. */
|
||||||
|
export type IUpdatePromptTemplateDTO = z.infer<typeof schemaUpdatePromptTemplate>;
|
||||||
|
|
||||||
|
// ========= SCHEMAS ========
|
||||||
|
|
||||||
|
export const schemaPromptTemplate = z.strictObject({
|
||||||
|
id: z.number(),
|
||||||
|
owner: z.number().nullable(),
|
||||||
|
label: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
text: z.string(),
|
||||||
|
is_shared: z.boolean()
|
||||||
|
});
|
||||||
|
|
||||||
|
const schemaPromptTemplateInput = schemaPromptTemplate
|
||||||
|
.pick({
|
||||||
|
is_shared: true,
|
||||||
|
owner: true
|
||||||
|
})
|
||||||
|
.extend({
|
||||||
|
label: z.string().max(limits.len_alias, errorMsg.aliasLength).nonempty(errorMsg.requiredField),
|
||||||
|
description: z.string().max(limits.len_description, errorMsg.descriptionLength),
|
||||||
|
text: z.string().max(limits.len_text, errorMsg.textLength)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemaCreatePromptTemplate = schemaPromptTemplateInput.omit({
|
||||||
|
owner: true
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemaUpdatePromptTemplate = schemaPromptTemplateInput;
|
||||||
|
|
||||||
|
export const schemaPromptTemplateInfo = schemaPromptTemplate.pick({
|
||||||
|
id: true,
|
||||||
|
owner: true,
|
||||||
|
label: true,
|
||||||
|
description: true,
|
||||||
|
is_shared: true
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemaPromptTemplateList = schemaPromptTemplateInfo.array();
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { queryClient } from '@/backend/query-client';
|
||||||
|
|
||||||
|
import { promptsApi } from './api';
|
||||||
|
|
||||||
|
export function useAvailableTemplates() {
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
...promptsApi.getAvailableTemplatesQueryOptions()
|
||||||
|
});
|
||||||
|
return { items: data, isLoading, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAvailableTemplatesSuspense() {
|
||||||
|
const { data } = useSuspenseQuery({
|
||||||
|
...promptsApi.getAvailableTemplatesQueryOptions()
|
||||||
|
});
|
||||||
|
return { items: data };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prefetchAvailableTemplates() {
|
||||||
|
return queryClient.prefetchQuery(promptsApi.getAvailableTemplatesQueryOptions());
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { KEYS } from '@/backend/configuration';
|
||||||
|
|
||||||
|
import { promptsApi } from './api';
|
||||||
|
|
||||||
|
export function useCreatePromptTemplate() {
|
||||||
|
const client = useQueryClient();
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationKey: [KEYS.global_mutation, promptsApi.baseKey, 'create'],
|
||||||
|
mutationFn: promptsApi.createPromptTemplate,
|
||||||
|
onSuccess: () => {
|
||||||
|
void client.invalidateQueries({ queryKey: [promptsApi.baseKey] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
createPromptTemplate: mutation.mutateAsync,
|
||||||
|
isPending: mutation.isPending,
|
||||||
|
error: mutation.error,
|
||||||
|
reset: mutation.reset
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { KEYS } from '@/backend/configuration';
|
||||||
|
|
||||||
|
import { promptsApi } from './api';
|
||||||
|
|
||||||
|
export function useDeletePromptTemplate() {
|
||||||
|
const client = useQueryClient();
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationKey: [KEYS.global_mutation, promptsApi.baseKey, 'delete'],
|
||||||
|
mutationFn: promptsApi.deletePromptTemplate,
|
||||||
|
onSuccess: (_, id) => {
|
||||||
|
void client.invalidateQueries({ queryKey: [promptsApi.baseKey] });
|
||||||
|
void client.invalidateQueries({ queryKey: [promptsApi.baseKey, id] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
deletePromptTemplate: mutation.mutateAsync,
|
||||||
|
isPending: mutation.isPending,
|
||||||
|
error: mutation.error,
|
||||||
|
reset: mutation.reset
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { useIsMutating } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { KEYS } from '@/backend/configuration';
|
||||||
|
|
||||||
|
import { promptsApi } from './api';
|
||||||
|
|
||||||
|
export const useMutatingPrompts = () => {
|
||||||
|
const countMutations = useIsMutating({ mutationKey: [KEYS.global_mutation, promptsApi.baseKey] });
|
||||||
|
return countMutations !== 0;
|
||||||
|
};
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { queryClient } from '@/backend/query-client';
|
||||||
|
|
||||||
|
import { promptsApi } from './api';
|
||||||
|
|
||||||
|
export function usePromptTemplate(id: number) {
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
...promptsApi.getPromptTemplateQueryOptions(id)
|
||||||
|
});
|
||||||
|
return { promptTemplate: data, isLoading, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePromptTemplateSuspense(id: number) {
|
||||||
|
const { data } = useSuspenseQuery({
|
||||||
|
...promptsApi.getPromptTemplateQueryOptions(id)
|
||||||
|
});
|
||||||
|
return { promptTemplate: data };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prefetchPromptTemplate({ itemID }: { itemID: number }) {
|
||||||
|
return queryClient.prefetchQuery(promptsApi.getPromptTemplateQueryOptions(itemID));
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { KEYS } from '@/backend/configuration';
|
||||||
|
|
||||||
|
import { promptsApi } from './api';
|
||||||
|
import { type IPromptTemplateDTO, type IUpdatePromptTemplateDTO } from './types';
|
||||||
|
|
||||||
|
export function useUpdatePromptTemplate() {
|
||||||
|
const client = useQueryClient();
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationKey: [KEYS.global_mutation, promptsApi.baseKey, 'update'],
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: IUpdatePromptTemplateDTO }) =>
|
||||||
|
promptsApi.updatePromptTemplate(id, data),
|
||||||
|
onSuccess: (data: IPromptTemplateDTO) => {
|
||||||
|
client.setQueryData(promptsApi.getPromptTemplateQueryOptions(data.id).queryKey, data);
|
||||||
|
client.setQueryData(promptsApi.getAvailableTemplatesQueryOptions().queryKey, prev =>
|
||||||
|
prev?.map(item => (item.id === data.id ? data : item))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
updatePromptTemplate: mutation.mutateAsync,
|
||||||
|
isPending: mutation.isPending,
|
||||||
|
error: mutation.error,
|
||||||
|
reset: mutation.reset
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { globalIDs } from '@/utils/constants';
|
||||||
|
|
||||||
|
import { IconSharedTemplate } from './icon-shared-template';
|
||||||
|
|
||||||
|
interface BadgeSharedTemplateProps {
|
||||||
|
value: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays location icon with a full text tooltip.
|
||||||
|
*/
|
||||||
|
export function BadgeSharedTemplate({ value }: BadgeSharedTemplateProps) {
|
||||||
|
return (
|
||||||
|
<div className='pl-2' data-tooltip-id={globalIDs.tooltip} data-tooltip-content={value ? 'Общий' : 'Личный'}>
|
||||||
|
<IconSharedTemplate value={value} size='1.25rem' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { type DomIconProps, IconPrivate, IconPublic } from '@/components/icons';
|
||||||
|
|
||||||
|
/** Icon for shared template flag. */
|
||||||
|
export function IconSharedTemplate({ value, size = '1.25rem', className }: DomIconProps<boolean>) {
|
||||||
|
if (value) {
|
||||||
|
return <IconPublic size={size} className={className ?? 'text-constructive'} />;
|
||||||
|
} else {
|
||||||
|
return <IconPrivate size={size} className={className ?? 'text-primary'} />;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { Suspense, useState } from 'react';
|
||||||
|
|
||||||
|
import { ComboBox } from '@/components/input/combo-box';
|
||||||
|
import { Loader } from '@/components/loader';
|
||||||
|
import { ModalView } from '@/components/modal';
|
||||||
|
import { TabLabel, TabList, TabPanel, Tabs } from '@/components/tabs';
|
||||||
|
|
||||||
|
import { useAvailableTemplatesSuspense } from '../../backend/use-available-templates';
|
||||||
|
|
||||||
|
import { TabPromptResult } from './tab-prompt-result';
|
||||||
|
import { TabPromptSelect } from './tab-prompt-select';
|
||||||
|
import { TabPromptVariables } from './tab-prompt-variables';
|
||||||
|
|
||||||
|
export const TabID = {
|
||||||
|
SELECT: 0,
|
||||||
|
RESULT: 1,
|
||||||
|
VARIABLES: 2
|
||||||
|
} as const;
|
||||||
|
type TabID = (typeof TabID)[keyof typeof TabID];
|
||||||
|
|
||||||
|
export function DlgAIPromptDialog() {
|
||||||
|
const [activeTab, setActiveTab] = useState<TabID>(TabID.SELECT);
|
||||||
|
const [selected, setSelected] = useState<number | null>(null);
|
||||||
|
const { items: prompts } = useAvailableTemplatesSuspense();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalView header='Генератор запросом LLM' className='w-100 sm:w-160 px-6'>
|
||||||
|
<Tabs selectedIndex={activeTab} onSelect={index => setActiveTab(index as TabID)}>
|
||||||
|
<TabList className='mb-3 mx-auto w-fit flex border divide-x rounded-none'>
|
||||||
|
<TabLabel label='Шаблон' />
|
||||||
|
<TabLabel label='Результат' disabled={!selected} />
|
||||||
|
<TabLabel label='Переменные' disabled={!selected} />
|
||||||
|
</TabList>
|
||||||
|
|
||||||
|
<div className='h-120 flex flex-col gap-2'>
|
||||||
|
<ComboBox
|
||||||
|
id='prompt-select'
|
||||||
|
items={prompts}
|
||||||
|
value={prompts?.find(p => p.id === selected) ?? null}
|
||||||
|
onChange={item => setSelected(item?.id ?? 0)}
|
||||||
|
idFunc={item => String(item.id)}
|
||||||
|
labelValueFunc={item => item.label}
|
||||||
|
labelOptionFunc={item => item.label}
|
||||||
|
placeholder='Выберите шаблон'
|
||||||
|
className='w-full'
|
||||||
|
/>
|
||||||
|
<Suspense fallback={<Loader />}>
|
||||||
|
<TabPanel>{selected ? <TabPromptSelect promptID={selected} /> : null}</TabPanel>
|
||||||
|
<TabPanel>{selected ? <TabPromptResult promptID={selected} /> : null}</TabPanel>
|
||||||
|
<TabPanel>{selected ? <TabPromptVariables promptID={selected} /> : null}</TabPanel>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
</ModalView>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './dlg-ai-prompt';
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
import { MiniButton } from '@/components/control';
|
||||||
|
import { IconClone } from '@/components/icons';
|
||||||
|
import { TextArea } from '@/components/input';
|
||||||
|
import { infoMsg } from '@/utils/labels';
|
||||||
|
|
||||||
|
import { usePromptTemplateSuspense } from '../../backend/use-prompt-template';
|
||||||
|
import { PromptVariableType } from '../../models/prompting';
|
||||||
|
import { extractPromptVariables } from '../../models/prompting-api';
|
||||||
|
import { evaluatePromptVariable, useAIStore } from '../../stores/ai-context';
|
||||||
|
|
||||||
|
interface TabPromptResultProps {
|
||||||
|
promptID: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabPromptResult({ promptID }: TabPromptResultProps) {
|
||||||
|
const { promptTemplate } = usePromptTemplateSuspense(promptID);
|
||||||
|
const context = useAIStore();
|
||||||
|
const variables = useMemo(() => {
|
||||||
|
return promptTemplate ? extractPromptVariables(promptTemplate.text) : [];
|
||||||
|
}, [promptTemplate]);
|
||||||
|
|
||||||
|
const generatedMessage = (() => {
|
||||||
|
if (!promptTemplate) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
let result = promptTemplate.text;
|
||||||
|
for (const variable of variables) {
|
||||||
|
const type = Object.values(PromptVariableType).find(t => t === variable);
|
||||||
|
let value = '';
|
||||||
|
if (type) {
|
||||||
|
value = evaluatePromptVariable(type, context) ?? '';
|
||||||
|
}
|
||||||
|
result = result.replace(new RegExp(`\{\{${variable}\}\}`, 'g'), value || `${variable}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
})();
|
||||||
|
|
||||||
|
function handleCopyPrompt() {
|
||||||
|
void navigator.clipboard.writeText(generatedMessage);
|
||||||
|
toast.success(infoMsg.promptReady);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='relative'>
|
||||||
|
<MiniButton
|
||||||
|
title='Скопировать в буфер обмена'
|
||||||
|
className='absolute -top-23 left-0'
|
||||||
|
icon={<IconClone size='1.25rem' className='icon-green' />}
|
||||||
|
onClick={handleCopyPrompt}
|
||||||
|
disabled={!generatedMessage}
|
||||||
|
/>
|
||||||
|
<TextArea
|
||||||
|
aria-label='Сгенерированное сообщение'
|
||||||
|
value={generatedMessage}
|
||||||
|
placeholder='Текст шаблона пуст'
|
||||||
|
disabled
|
||||||
|
fitContent
|
||||||
|
className='w-full max-h-100 min-h-12'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { TextArea } from '@/components/input';
|
||||||
|
|
||||||
|
import { usePromptTemplateSuspense } from '../../backend/use-prompt-template';
|
||||||
|
|
||||||
|
interface TabPromptSelectProps {
|
||||||
|
promptID: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabPromptSelect({ promptID }: TabPromptSelectProps) {
|
||||||
|
const { promptTemplate } = usePromptTemplateSuspense(promptID);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='cc-column'>
|
||||||
|
{promptTemplate && (
|
||||||
|
<div className='flex flex-col gap-2'>
|
||||||
|
<TextArea
|
||||||
|
id='prompt-label'
|
||||||
|
label='Название' //
|
||||||
|
value={promptTemplate.label}
|
||||||
|
disabled
|
||||||
|
noResize
|
||||||
|
rows={1}
|
||||||
|
/>
|
||||||
|
<TextArea
|
||||||
|
id='prompt-description'
|
||||||
|
label='Описание'
|
||||||
|
value={promptTemplate.description}
|
||||||
|
disabled
|
||||||
|
noResize
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<TextArea
|
||||||
|
id='prompt-text' //
|
||||||
|
label='Текст шаблона'
|
||||||
|
value={promptTemplate.text}
|
||||||
|
disabled
|
||||||
|
noResize
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { usePromptTemplateSuspense } from '../../backend/use-prompt-template';
|
||||||
|
import { describePromptVariable } from '../../labels';
|
||||||
|
import { PromptVariableType } from '../../models/prompting';
|
||||||
|
import { extractPromptVariables } from '../../models/prompting-api';
|
||||||
|
import { useAvailableVariables } from '../../stores/use-available-variables';
|
||||||
|
|
||||||
|
interface TabPromptVariablesProps {
|
||||||
|
promptID: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabPromptVariables({ promptID }: TabPromptVariablesProps) {
|
||||||
|
const { promptTemplate } = usePromptTemplateSuspense(promptID);
|
||||||
|
const variables = extractPromptVariables(promptTemplate.text);
|
||||||
|
const availableTypes = useAvailableVariables();
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{variables.length === 0 && <li>Нет переменных</li>}
|
||||||
|
{variables.map(variable => {
|
||||||
|
const type = Object.values(PromptVariableType).find(t => t === variable);
|
||||||
|
const isAvailable = !!type && availableTypes.includes(type);
|
||||||
|
return (
|
||||||
|
<li key={variable} className={isAvailable ? 'text-green-700 font-bold' : 'text-gray-500'}>
|
||||||
|
{variable} — {type ? describePromptVariable(type) : 'Неизвестная переменная'}
|
||||||
|
{isAvailable ? ' (доступна)' : ' (нет в контексте)'}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { Controller, useForm, useWatch } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
|
||||||
|
import { useAuthSuspense } from '@/features/auth';
|
||||||
|
|
||||||
|
import { Checkbox, TextArea, TextInput } from '@/components/input';
|
||||||
|
import { ModalForm } from '@/components/modal';
|
||||||
|
import { useDialogsStore } from '@/stores/dialogs';
|
||||||
|
import { type RO } from '@/utils/meta';
|
||||||
|
|
||||||
|
import { type ICreatePromptTemplateDTO, type IPromptTemplateDTO, schemaCreatePromptTemplate } from '../backend/types';
|
||||||
|
import { useAvailableTemplatesSuspense } from '../backend/use-available-templates';
|
||||||
|
import { useCreatePromptTemplate } from '../backend/use-create-prompt-template';
|
||||||
|
|
||||||
|
export interface DlgCreatePromptTemplateProps {
|
||||||
|
onCreate?: (data: RO<IPromptTemplateDTO>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DlgCreatePromptTemplate() {
|
||||||
|
const { onCreate } = useDialogsStore(state => state.props as DlgCreatePromptTemplateProps);
|
||||||
|
const { createPromptTemplate } = useCreatePromptTemplate();
|
||||||
|
const { items: templates } = useAvailableTemplatesSuspense();
|
||||||
|
const { user } = useAuthSuspense();
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
register,
|
||||||
|
formState: { errors }
|
||||||
|
} = useForm<ICreatePromptTemplateDTO>({
|
||||||
|
resolver: zodResolver(schemaCreatePromptTemplate),
|
||||||
|
defaultValues: {
|
||||||
|
label: '',
|
||||||
|
description: '',
|
||||||
|
text: '',
|
||||||
|
is_shared: false
|
||||||
|
},
|
||||||
|
mode: 'onChange'
|
||||||
|
});
|
||||||
|
const label = useWatch({ control, name: 'label' });
|
||||||
|
const isValid = !!label && !templates.find(template => template.label === label);
|
||||||
|
|
||||||
|
function onSubmit(data: ICreatePromptTemplateDTO) {
|
||||||
|
void createPromptTemplate(data).then(onCreate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalForm
|
||||||
|
header='Создание шаблона'
|
||||||
|
submitText='Создать'
|
||||||
|
canSubmit={isValid}
|
||||||
|
onSubmit={event => void handleSubmit(onSubmit)(event)}
|
||||||
|
submitInvalidTooltip='Введите уникальное название шаблона'
|
||||||
|
className='cc-column w-140 max-h-120 py-2 px-6'
|
||||||
|
>
|
||||||
|
<TextInput id='dlg_prompt_label' {...register('label')} label='Название шаблона' error={errors.label} />
|
||||||
|
<TextArea id='dlg_prompt_description' {...register('description')} label='Описание' error={errors.description} />
|
||||||
|
{user.is_staff ? (
|
||||||
|
<Controller
|
||||||
|
name='is_shared'
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Checkbox
|
||||||
|
id='dlg_prompt_is_shared'
|
||||||
|
label='Общий шаблон'
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
ref={field.ref}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</ModalForm>
|
||||||
|
);
|
||||||
|
}
|
28
rsconcept/frontend/src/features/ai/labels.ts
Normal file
28
rsconcept/frontend/src/features/ai/labels.ts
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import { PromptVariableType } from './models/prompting';
|
||||||
|
|
||||||
|
const describePromptVariableRecord: Record<PromptVariableType, string> = {
|
||||||
|
[PromptVariableType.BLOCK]: 'Текущий блок операционной схемы',
|
||||||
|
[PromptVariableType.OSS]: 'Текущая операционная схема',
|
||||||
|
[PromptVariableType.SCHEMA]: 'Текущая концептуальная схема',
|
||||||
|
[PromptVariableType.CONSTITUENTA]: 'Текущая конституента'
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPromptVariableRecord: Record<PromptVariableType, string> = {
|
||||||
|
[PromptVariableType.BLOCK]: 'Пример: Текущий блок операционной схемы',
|
||||||
|
[PromptVariableType.OSS]: 'Пример: Текущая операционная схема',
|
||||||
|
[PromptVariableType.SCHEMA]: 'Пример: Текущая концептуальная схема',
|
||||||
|
[PromptVariableType.CONSTITUENTA]: 'Пример: Текущая конституента'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Retrieves description for {@link PromptVariableType}. */
|
||||||
|
export function describePromptVariable(itemType: PromptVariableType): string {
|
||||||
|
return describePromptVariableRecord[itemType] ?? `UNKNOWN VARIABLE TYPE: ${itemType}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retrieves mock text for {@link PromptVariableType}. */
|
||||||
|
export function mockPromptVariable(variable: string): string {
|
||||||
|
if (!Object.values(PromptVariableType).includes(variable as PromptVariableType)) {
|
||||||
|
return variable;
|
||||||
|
}
|
||||||
|
return mockPromptVariableRecord[variable as PromptVariableType] ?? `UNKNOWN VARIABLE: ${variable}`;
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { extractPromptVariables } from './prompting-api';
|
||||||
|
|
||||||
|
describe('extractPromptVariables', () => {
|
||||||
|
it('extracts a single variable', () => {
|
||||||
|
expect(extractPromptVariables('Hello {{name}}!')).toEqual(['name']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts multiple variables', () => {
|
||||||
|
expect(extractPromptVariables('Hi {{firstName}}, your ID is {{user.id}}.')).toEqual(['firstName', 'user.id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts variables with hyphens and dots', () => {
|
||||||
|
expect(extractPromptVariables('Welcome {{user-name}} and {{user.name}}!')).toEqual(['user-name', 'user.name']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array if no variables', () => {
|
||||||
|
expect(extractPromptVariables('No variables here!')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores invalid variable patterns', () => {
|
||||||
|
expect(extractPromptVariables('Hello {name}, {{name!}}, {{123}}, {{user_name}}')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts repeated variables', () => {
|
||||||
|
expect(extractPromptVariables('Repeat: {{foo}}, again: {{foo}}')).toEqual(['foo', 'foo']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with adjacent variables', () => {
|
||||||
|
expect(extractPromptVariables('{{a}}{{b}}{{c}}')).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for empty string', () => {
|
||||||
|
expect(extractPromptVariables('')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts variables at string boundaries', () => {
|
||||||
|
expect(extractPromptVariables('{{start}} middle {{end}}')).toEqual(['start', 'end']);
|
||||||
|
});
|
||||||
|
});
|
29
rsconcept/frontend/src/features/ai/models/prompting-api.ts
Normal file
29
rsconcept/frontend/src/features/ai/models/prompting-api.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import { mockPromptVariable } from '../labels';
|
||||||
|
|
||||||
|
/** Extracts a list of variables (as string[]) from a target string.
|
||||||
|
* Note: Variables are wrapped in {{...}} and can include a-zA-Z, hyphen, and dot inside curly braces.
|
||||||
|
* */
|
||||||
|
export function extractPromptVariables(target: string): string[] {
|
||||||
|
const regex = /\{\{([a-zA-Z.-]+)\}\}/g;
|
||||||
|
const result: string[] = [];
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = regex.exec(target)) !== null) {
|
||||||
|
result.push(match[1]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generates a sample text from a target templates. */
|
||||||
|
export function generateSample(target: string): string {
|
||||||
|
const variables = extractPromptVariables(target);
|
||||||
|
if (variables.length === 0) {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
let result = target;
|
||||||
|
for (const variable of variables) {
|
||||||
|
const mockText = mockPromptVariable(variable);
|
||||||
|
const escapedVar = variable.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
result = result.replace(new RegExp(`\\{\\{${escapedVar}\\}\\}`, 'g'), mockText);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
29
rsconcept/frontend/src/features/ai/models/prompting.ts
Normal file
29
rsconcept/frontend/src/features/ai/models/prompting.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
/** Represents prompt variable type. */
|
||||||
|
export const PromptVariableType = {
|
||||||
|
BLOCK: 'block',
|
||||||
|
// BLOCK_TITLE: 'block.title',
|
||||||
|
// BLOCK_DESCRIPTION: 'block.description',
|
||||||
|
// BLOCK_CONTENTS: 'block.contents',
|
||||||
|
|
||||||
|
OSS: 'oss',
|
||||||
|
// OSS_CONTENTS: 'oss.contents',
|
||||||
|
// OSS_ALIAS: 'oss.alias',
|
||||||
|
// OSS_TITLE: 'oss.title',
|
||||||
|
// OSS_DESCRIPTION: 'oss.description',
|
||||||
|
|
||||||
|
SCHEMA: 'schema',
|
||||||
|
// SCHEMA_ALIAS: 'schema.alias',
|
||||||
|
// SCHEMA_TITLE: 'schema.title',
|
||||||
|
// SCHEMA_DESCRIPTION: 'schema.description',
|
||||||
|
// SCHEMA_THESAURUS: 'schema.thesaurus',
|
||||||
|
// SCHEMA_GRAPH: 'schema.graph',
|
||||||
|
// SCHEMA_TYPE_GRAPH: 'schema.type-graph',
|
||||||
|
|
||||||
|
CONSTITUENTA: 'constituenta'
|
||||||
|
// CONSTITUENTA_ALIAS: 'constituent.alias',
|
||||||
|
// CONSTITUENTA_CONVENTION: 'constituent.convention',
|
||||||
|
// CONSTITUENTA_DEFINITION: 'constituent.definition',
|
||||||
|
// CONSTITUENTA_DEFINITION_FORMAL: 'constituent.definition-formal',
|
||||||
|
// CONSTITUENTA_EXPRESSION_TREE: 'constituent.expression-tree'
|
||||||
|
} as const;
|
||||||
|
export type PromptVariableType = (typeof PromptVariableType)[keyof typeof PromptVariableType];
|
|
@ -0,0 +1 @@
|
||||||
|
export { PromptTemplatesPage as Component } from './prompt-templates-page';
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { urls, useConceptNavigation } from '@/app';
|
||||||
|
|
||||||
|
import { MiniButton } from '@/components/control';
|
||||||
|
import { IconNewItem } from '@/components/icons';
|
||||||
|
import { useDialogsStore } from '@/stores/dialogs';
|
||||||
|
|
||||||
|
import { PromptTabID } from './templates-tabs';
|
||||||
|
|
||||||
|
export function MenuTemplates() {
|
||||||
|
const router = useConceptNavigation();
|
||||||
|
const showCreatePromptTemplate = useDialogsStore(state => state.showCreatePromptTemplate);
|
||||||
|
|
||||||
|
function handleNewTemplate() {
|
||||||
|
showCreatePromptTemplate({
|
||||||
|
onCreate: data => router.push({ path: urls.prompt_template(data.id, PromptTabID.EDIT) })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex border-r-2 px-2'>
|
||||||
|
<MiniButton
|
||||||
|
noHover
|
||||||
|
noPadding
|
||||||
|
title='Новый шаблон'
|
||||||
|
icon={<IconNewItem size='1.25rem' />}
|
||||||
|
className='h-full text-muted-foreground hover:text-constructive cc-animate-color bg-transparent'
|
||||||
|
onClick={handleNewTemplate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { ErrorBoundary } from 'react-error-boundary';
|
||||||
|
import { isAxiosError } from 'axios';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { useBlockNavigation } from '@/app';
|
||||||
|
import { routes } from '@/app/urls';
|
||||||
|
import { RequireAuth } from '@/features/auth/components/require-auth';
|
||||||
|
|
||||||
|
import { TextURL } from '@/components/control';
|
||||||
|
import { type ErrorData } from '@/components/info-error';
|
||||||
|
import { useQueryStrings } from '@/hooks/use-query-strings';
|
||||||
|
import { useModificationStore } from '@/stores/modification';
|
||||||
|
|
||||||
|
import { PromptTabID, TemplatesTabs } from './templates-tabs';
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
tab: z.preprocess(v => (v ? Number(v) : undefined), z.nativeEnum(PromptTabID).default(PromptTabID.LIST)),
|
||||||
|
active: z.preprocess(v => (v ? Number(v) : undefined), z.number().nullable().default(null))
|
||||||
|
});
|
||||||
|
|
||||||
|
export function PromptTemplatesPage() {
|
||||||
|
const query = useQueryStrings();
|
||||||
|
|
||||||
|
const urlData = paramsSchema.parse({
|
||||||
|
tab: query.get('tab'),
|
||||||
|
active: query.get('active')
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isModified } = useModificationStore();
|
||||||
|
useBlockNavigation(isModified);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary
|
||||||
|
FallbackComponent={({ error }) => <ProcessError error={error as ErrorData} itemID={urlData.active} />}
|
||||||
|
>
|
||||||
|
<RequireAuth>
|
||||||
|
<TemplatesTabs activeID={urlData.active} tab={urlData.tab} />
|
||||||
|
</RequireAuth>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Internals =========
|
||||||
|
function ProcessError({ error, itemID }: { error: ErrorData; itemID?: number | null }): React.ReactElement | null {
|
||||||
|
if (isAxiosError(error) && error.response) {
|
||||||
|
if (error.response.status === 404) {
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col items-center p-2 mx-auto'>
|
||||||
|
<p>{`Шаблон запроса с указанным идентификатором ${itemID} отсутствует`}</p>
|
||||||
|
<div className='flex justify-center'>
|
||||||
|
<TextURL text='Список шаблонов' href={`/${routes.prompt_templates}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error as Error;
|
||||||
|
}
|
|
@ -0,0 +1,154 @@
|
||||||
|
'use no memo'; // TODO: remove when react hook forms are compliant with react compiler
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { Controller, useForm, useWatch } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useDebounce } from 'use-debounce';
|
||||||
|
|
||||||
|
import { useAuthSuspense } from '@/features/auth';
|
||||||
|
|
||||||
|
import { MiniButton } from '@/components/control';
|
||||||
|
import { IconSample } from '@/components/icons';
|
||||||
|
import { Checkbox, TextArea, TextInput } from '@/components/input';
|
||||||
|
import { cn } from '@/components/utils';
|
||||||
|
import { useModificationStore } from '@/stores/modification';
|
||||||
|
import { globalIDs, PARAMETER } from '@/utils/constants';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type IPromptTemplate,
|
||||||
|
type IUpdatePromptTemplateDTO,
|
||||||
|
schemaUpdatePromptTemplate
|
||||||
|
} from '../../../backend/types';
|
||||||
|
import { useMutatingPrompts } from '../../../backend/use-mutating-prompts';
|
||||||
|
import { useUpdatePromptTemplate } from '../../../backend/use-update-prompt-template';
|
||||||
|
import { generateSample } from '../../../models/prompting-api';
|
||||||
|
|
||||||
|
interface FormPromptTemplateProps {
|
||||||
|
promptTemplate: IPromptTemplate;
|
||||||
|
isMutable: boolean;
|
||||||
|
className?: string;
|
||||||
|
toggleReset: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Form for editing a prompt template. */
|
||||||
|
export function FormPromptTemplate({ promptTemplate, className, isMutable, toggleReset }: FormPromptTemplateProps) {
|
||||||
|
const { user } = useAuthSuspense();
|
||||||
|
const isProcessing = useMutatingPrompts();
|
||||||
|
const setIsModified = useModificationStore(state => state.setIsModified);
|
||||||
|
const { updatePromptTemplate } = useUpdatePromptTemplate();
|
||||||
|
const [sampleResult, setSampleResult] = useState<string | null>(null);
|
||||||
|
const [debouncedResult] = useDebounce(sampleResult, PARAMETER.moveDuration);
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
register,
|
||||||
|
formState: { isDirty, errors }
|
||||||
|
} = useForm<IUpdatePromptTemplateDTO>({
|
||||||
|
resolver: zodResolver(schemaUpdatePromptTemplate),
|
||||||
|
defaultValues: {
|
||||||
|
owner: promptTemplate.owner,
|
||||||
|
label: promptTemplate.label,
|
||||||
|
description: promptTemplate.description,
|
||||||
|
text: promptTemplate.text,
|
||||||
|
is_shared: promptTemplate.is_shared
|
||||||
|
},
|
||||||
|
mode: 'onChange'
|
||||||
|
});
|
||||||
|
const text = useWatch({ control, name: 'text' });
|
||||||
|
|
||||||
|
const prevReset = useRef(toggleReset);
|
||||||
|
const prevTemplate = useRef(promptTemplate);
|
||||||
|
if (prevTemplate.current !== promptTemplate || prevReset.current !== toggleReset) {
|
||||||
|
prevTemplate.current = promptTemplate;
|
||||||
|
prevReset.current = toggleReset;
|
||||||
|
reset({
|
||||||
|
owner: promptTemplate.owner,
|
||||||
|
label: promptTemplate.label,
|
||||||
|
description: promptTemplate.description,
|
||||||
|
text: promptTemplate.text,
|
||||||
|
is_shared: promptTemplate.is_shared
|
||||||
|
});
|
||||||
|
setSampleResult(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevDirty = useRef(isDirty);
|
||||||
|
if (prevDirty.current !== isDirty) {
|
||||||
|
prevDirty.current = isDirty;
|
||||||
|
setIsModified(isDirty);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubmit(data: IUpdatePromptTemplateDTO) {
|
||||||
|
return updatePromptTemplate({ id: promptTemplate.id, data }).then(() => {
|
||||||
|
setIsModified(false);
|
||||||
|
reset({ ...data });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
id={globalIDs.prompt_editor}
|
||||||
|
className={cn('flex flex-col gap-3 px-6 py-2', className)}
|
||||||
|
onSubmit={event => void handleSubmit(onSubmit)(event)}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
id='prompt_label'
|
||||||
|
label='Название' //
|
||||||
|
{...register('label')}
|
||||||
|
error={errors.label}
|
||||||
|
disabled={isProcessing || !isMutable}
|
||||||
|
/>
|
||||||
|
<TextArea
|
||||||
|
id='prompt_description'
|
||||||
|
label='Описание' //
|
||||||
|
{...register('description')}
|
||||||
|
error={errors.description}
|
||||||
|
disabled={isProcessing || !isMutable}
|
||||||
|
/>
|
||||||
|
<TextArea
|
||||||
|
id='prompt_text'
|
||||||
|
label='Содержание' //
|
||||||
|
fitContent
|
||||||
|
className='disabled:min-h-9 max-h-64'
|
||||||
|
{...register('text')}
|
||||||
|
error={errors.text}
|
||||||
|
disabled={isProcessing || !isMutable}
|
||||||
|
/>
|
||||||
|
<div className='flex justify-between'>
|
||||||
|
<Controller
|
||||||
|
name='is_shared'
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Checkbox
|
||||||
|
id='prompt_is_shared'
|
||||||
|
label='Общий шаблон'
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
ref={field.ref}
|
||||||
|
disabled={isProcessing || !isMutable || !user.is_staff}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<MiniButton
|
||||||
|
title='Сгенерировать пример запроса'
|
||||||
|
icon={<IconSample size='1.25rem' className='icon-primary' />}
|
||||||
|
onClick={() => setSampleResult(!!sampleResult ? null : generateSample(text))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={clsx('cc-prompt-result overflow-y-hidden', sampleResult !== null && 'open')}>
|
||||||
|
<TextArea
|
||||||
|
fitContent
|
||||||
|
className='mt-3 max-h-64 min-h-12'
|
||||||
|
label='Пример запроса'
|
||||||
|
value={sampleResult ?? debouncedResult ?? ''}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export { TabEditTemplate } from './tab-edit-template';
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
import { useAuthSuspense } from '@/features/auth';
|
||||||
|
|
||||||
|
import { useModificationStore } from '@/stores/modification';
|
||||||
|
import { globalIDs } from '@/utils/constants';
|
||||||
|
|
||||||
|
import { usePromptTemplateSuspense } from '../../../backend/use-prompt-template';
|
||||||
|
|
||||||
|
import { FormPromptTemplate } from './form-prompt-template';
|
||||||
|
import { ToolbarTemplate } from './toolbar-template';
|
||||||
|
|
||||||
|
interface TabEditTemplateProps {
|
||||||
|
activeID: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabEditTemplate({ activeID }: TabEditTemplateProps) {
|
||||||
|
const { promptTemplate } = usePromptTemplateSuspense(activeID);
|
||||||
|
const isModified = useModificationStore(state => state.isModified);
|
||||||
|
const setIsModified = useModificationStore(state => state.setIsModified);
|
||||||
|
const { user } = useAuthSuspense();
|
||||||
|
const isMutable = user.is_staff || promptTemplate.owner === user.id;
|
||||||
|
const [toggleReset, setToggleReset] = useState(false);
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
setToggleReset(t => !t);
|
||||||
|
setIsModified(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerFormSubmit() {
|
||||||
|
const form = document.getElementById(globalIDs.prompt_editor) as HTMLFormElement | null;
|
||||||
|
if (form) {
|
||||||
|
form.requestSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput(event: React.KeyboardEvent<HTMLDivElement>) {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') {
|
||||||
|
if (isModified) {
|
||||||
|
triggerFormSubmit();
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='pt-8 rounded bg-background relative' tabIndex={-1} onKeyDown={handleInput}>
|
||||||
|
{isMutable ? (
|
||||||
|
<ToolbarTemplate
|
||||||
|
activeID={activeID}
|
||||||
|
className={clsx(
|
||||||
|
'cc-tab-tools cc-animate-position',
|
||||||
|
'right-1/2 translate-x-0 xs:right-4 xs:-translate-x-1/2 md:right-1/2 md:translate-x-0'
|
||||||
|
)}
|
||||||
|
onSave={triggerFormSubmit}
|
||||||
|
onReset={handleReset}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<FormPromptTemplate
|
||||||
|
className='mt-8 xs:mt-0 w-100 md:w-180 min-w-70'
|
||||||
|
isMutable={isMutable}
|
||||||
|
promptTemplate={promptTemplate}
|
||||||
|
toggleReset={toggleReset}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { urls, useConceptNavigation } from '@/app';
|
||||||
|
import { useDeletePromptTemplate } from '@/features/ai/backend/use-delete-prompt-template';
|
||||||
|
import { useMutatingPrompts } from '@/features/ai/backend/use-mutating-prompts';
|
||||||
|
|
||||||
|
import { MiniButton } from '@/components/control';
|
||||||
|
import { IconDestroy, IconReset, IconSave } from '@/components/icons';
|
||||||
|
import { cn } from '@/components/utils';
|
||||||
|
import { useModificationStore } from '@/stores/modification';
|
||||||
|
import { promptText } from '@/utils/labels';
|
||||||
|
import { isMac, prepareTooltip } from '@/utils/utils';
|
||||||
|
|
||||||
|
import { PromptTabID } from '../templates-tabs';
|
||||||
|
|
||||||
|
interface ToolbarTemplateProps {
|
||||||
|
activeID: number;
|
||||||
|
onSave: () => void;
|
||||||
|
onReset: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toolbar for prompt template editing. */
|
||||||
|
export function ToolbarTemplate({ activeID, onSave, onReset, className }: ToolbarTemplateProps) {
|
||||||
|
const router = useConceptNavigation();
|
||||||
|
const { deletePromptTemplate } = useDeletePromptTemplate();
|
||||||
|
const isProcessing = useMutatingPrompts();
|
||||||
|
const isModified = useModificationStore(state => state.isModified);
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
if (window.confirm(promptText.deleteTemplate)) {
|
||||||
|
void deletePromptTemplate(activeID).then(() =>
|
||||||
|
router.pushAsync({ path: urls.prompt_template(null, PromptTabID.LIST) })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('cc-icons items-start outline-hidden', className)}>
|
||||||
|
<MiniButton
|
||||||
|
titleHtml={prepareTooltip('Сохранить изменения', isMac() ? 'Cmd + S' : 'Ctrl + S')}
|
||||||
|
aria-label='Сохранить изменения'
|
||||||
|
icon={<IconSave size='1.25rem' className='icon-primary' />}
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={isProcessing || !isModified}
|
||||||
|
/>
|
||||||
|
<MiniButton
|
||||||
|
title='Сбросить изменения'
|
||||||
|
aria-label='Сбросить изменения'
|
||||||
|
icon={<IconReset size='1.25rem' className='icon-primary' />}
|
||||||
|
onClick={onReset}
|
||||||
|
disabled={isProcessing || !isModified}
|
||||||
|
/>
|
||||||
|
<MiniButton
|
||||||
|
title='Удалить шаблон'
|
||||||
|
aria-label='Удалить шаблон'
|
||||||
|
icon={<IconDestroy size='1.25rem' className='icon-red' />}
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isProcessing}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { urls, useConceptNavigation } from '@/app';
|
||||||
|
import { type IPromptTemplate } from '@/features/ai/backend/types';
|
||||||
|
import { useLabelUser } from '@/features/users';
|
||||||
|
|
||||||
|
import { TextURL } from '@/components/control';
|
||||||
|
import { createColumnHelper, DataTable, type IConditionalStyle } from '@/components/data-table';
|
||||||
|
import { NoData } from '@/components/view';
|
||||||
|
import { useDialogsStore } from '@/stores/dialogs';
|
||||||
|
import { type RO } from '@/utils/meta';
|
||||||
|
|
||||||
|
import { useAvailableTemplatesSuspense } from '../../backend/use-available-templates';
|
||||||
|
import { BadgeSharedTemplate } from '../../components/badge-shared-template';
|
||||||
|
|
||||||
|
import { PromptTabID } from './templates-tabs';
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<RO<IPromptTemplate>>();
|
||||||
|
|
||||||
|
interface TabListTemplatesProps {
|
||||||
|
activeID: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabListTemplates({ activeID }: TabListTemplatesProps) {
|
||||||
|
const router = useConceptNavigation();
|
||||||
|
const { items } = useAvailableTemplatesSuspense();
|
||||||
|
const showCreatePromptTemplate = useDialogsStore(state => state.showCreatePromptTemplate);
|
||||||
|
const getUserLabel = useLabelUser();
|
||||||
|
|
||||||
|
function handleRowDoubleClicked(row: RO<IPromptTemplate>, event: React.MouseEvent<Element>) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
router.push({ path: urls.prompt_template(row.id, PromptTabID.EDIT), newTab: event.ctrlKey || event.metaKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRowClicked(row: RO<IPromptTemplate>, event: React.MouseEvent<Element>) {
|
||||||
|
if (row.id === activeID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push({ path: urls.prompt_template(row.id, PromptTabID.LIST), newTab: event.ctrlKey || event.metaKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateNew() {
|
||||||
|
showCreatePromptTemplate({});
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
columnHelper.accessor('is_shared', {
|
||||||
|
id: 'is_shared',
|
||||||
|
header: '',
|
||||||
|
size: 50,
|
||||||
|
minSize: 50,
|
||||||
|
maxSize: 50,
|
||||||
|
enableSorting: true,
|
||||||
|
cell: props => <BadgeSharedTemplate value={props.getValue()} />,
|
||||||
|
sortingFn: 'text'
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('label', {
|
||||||
|
id: 'label',
|
||||||
|
header: 'Название',
|
||||||
|
size: 200,
|
||||||
|
minSize: 200,
|
||||||
|
maxSize: 200,
|
||||||
|
enableSorting: true,
|
||||||
|
cell: props => <span className='min-w-20'>{props.getValue()}</span>,
|
||||||
|
sortingFn: 'text'
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('description', {
|
||||||
|
id: 'description',
|
||||||
|
header: 'Описание',
|
||||||
|
size: 1200,
|
||||||
|
minSize: 200,
|
||||||
|
maxSize: 1200,
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: 'text'
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('owner', {
|
||||||
|
id: 'owner',
|
||||||
|
header: 'Владелец',
|
||||||
|
size: 400,
|
||||||
|
minSize: 100,
|
||||||
|
maxSize: 400,
|
||||||
|
cell: props => getUserLabel(props.getValue()),
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: 'text'
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
const conditionalRowStyles: IConditionalStyle<RO<IPromptTemplate>>[] = [
|
||||||
|
{
|
||||||
|
when: (template: RO<IPromptTemplate>) => template.id === activeID,
|
||||||
|
className: 'bg-selected'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='pt-7 relative'>
|
||||||
|
<DataTable
|
||||||
|
noFooter
|
||||||
|
enableSorting
|
||||||
|
data={items as IPromptTemplate[]}
|
||||||
|
columns={columns}
|
||||||
|
className='w-full h-full border-x border-b'
|
||||||
|
onRowClicked={handleRowClicked}
|
||||||
|
onRowDoubleClicked={handleRowDoubleClicked}
|
||||||
|
conditionalRowStyles={conditionalRowStyles}
|
||||||
|
noDataComponent={
|
||||||
|
<NoData>
|
||||||
|
<p>Список пуст</p>
|
||||||
|
<p>
|
||||||
|
<TextURL text='Создать шаблон запроса...' onClick={handleCreateNew} />
|
||||||
|
</p>
|
||||||
|
</NoData>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { describePromptVariable } from '../../labels';
|
||||||
|
import { PromptVariableType } from '../../models/prompting';
|
||||||
|
|
||||||
|
/** Displays all prompt variable types with their descriptions. */
|
||||||
|
export function TabViewVariables() {
|
||||||
|
return (
|
||||||
|
<div className='pt-8'>
|
||||||
|
<ul className='space-y-1'>
|
||||||
|
{Object.values(PromptVariableType).map(variableType => (
|
||||||
|
<li key={variableType} className='flex flex-col'>
|
||||||
|
<span className='font-math text-primary'>{`{{${variableType}}}`}</span>
|
||||||
|
<span className='font-main text-muted-foreground'>{describePromptVariable(variableType)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
import { urls, useConceptNavigation } from '@/app';
|
||||||
|
|
||||||
|
import { Loader } from '@/components/loader';
|
||||||
|
import { TabLabel, TabList, TabPanel, Tabs } from '@/components/tabs';
|
||||||
|
|
||||||
|
import { MenuTemplates } from './menu-templates';
|
||||||
|
import { TabEditTemplate } from './tab-edit-template';
|
||||||
|
import { TabListTemplates } from './tab-list-templates';
|
||||||
|
import { TabViewVariables } from './tab-view-variables';
|
||||||
|
|
||||||
|
export const PromptTabID = {
|
||||||
|
LIST: 0,
|
||||||
|
EDIT: 1,
|
||||||
|
VARIABLES: 2
|
||||||
|
} as const;
|
||||||
|
export type PromptTabID = (typeof PromptTabID)[keyof typeof PromptTabID];
|
||||||
|
|
||||||
|
interface TemplatesTabsProps {
|
||||||
|
activeID: number | null;
|
||||||
|
tab: PromptTabID;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabLoader() {
|
||||||
|
return (
|
||||||
|
<div className='h-20 mt-8 w-full flex items-center'>
|
||||||
|
<Loader scale={4} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplatesTabs({ activeID, tab }: TemplatesTabsProps) {
|
||||||
|
const router = useConceptNavigation();
|
||||||
|
|
||||||
|
function onSelectTab(index: number, last: number, event: Event) {
|
||||||
|
if (last === index) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.type == 'keydown') {
|
||||||
|
const kbEvent = event as KeyboardEvent;
|
||||||
|
if (kbEvent.altKey) {
|
||||||
|
if (kbEvent.code === 'ArrowLeft') {
|
||||||
|
router.back();
|
||||||
|
return;
|
||||||
|
} else if (kbEvent.code === 'ArrowRight') {
|
||||||
|
router.forward();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
router.replace({ path: urls.prompt_template(activeID, index as PromptTabID) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs selectedIndex={tab} onSelect={onSelectTab} className='relative flex flex-col min-w-fit items-center'>
|
||||||
|
<TabList className='absolute z-sticky flex border-b-2 border-x-2 divide-x-2 bg-background'>
|
||||||
|
<MenuTemplates />
|
||||||
|
<TabLabel label='Список' />
|
||||||
|
<TabLabel label='Шаблон' />
|
||||||
|
<TabLabel label='Переменные' />
|
||||||
|
</TabList>
|
||||||
|
<div className='overflow-x-hidden'>
|
||||||
|
<TabPanel>
|
||||||
|
<Suspense fallback={<TabLoader />}>
|
||||||
|
<TabListTemplates activeID={activeID} />
|
||||||
|
</Suspense>
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel>
|
||||||
|
{activeID ? (
|
||||||
|
<Suspense fallback={<TabLoader />}>
|
||||||
|
{' '}
|
||||||
|
<TabEditTemplate activeID={activeID} />{' '}
|
||||||
|
</Suspense>
|
||||||
|
) : null}
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel>
|
||||||
|
<TabViewVariables />
|
||||||
|
</TabPanel>
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
78
rsconcept/frontend/src/features/ai/stores/ai-context.ts
Normal file
78
rsconcept/frontend/src/features/ai/stores/ai-context.ts
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
import { type IBlock, type IOperationSchema } from '@/features/oss/models/oss';
|
||||||
|
import { type IConstituenta, type IRSForm } from '@/features/rsform';
|
||||||
|
import { labelCstTypification } from '@/features/rsform/labels';
|
||||||
|
|
||||||
|
import { PromptVariableType } from '../models/prompting';
|
||||||
|
|
||||||
|
interface AIContextStore {
|
||||||
|
currentOSS: IOperationSchema | null;
|
||||||
|
setCurrentOSS: (value: IOperationSchema | null) => void;
|
||||||
|
|
||||||
|
currentSchema: IRSForm | null;
|
||||||
|
setCurrentSchema: (value: IRSForm | null) => void;
|
||||||
|
|
||||||
|
currentBlock: IBlock | null;
|
||||||
|
setCurrentBlock: (value: IBlock | null) => void;
|
||||||
|
|
||||||
|
currentConstituenta: IConstituenta | null;
|
||||||
|
setCurrentConstituenta: (value: IConstituenta | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAIStore = create<AIContextStore>()(set => ({
|
||||||
|
currentOSS: null,
|
||||||
|
setCurrentOSS: value => set({ currentOSS: value }),
|
||||||
|
|
||||||
|
currentSchema: null,
|
||||||
|
setCurrentSchema: value => set({ currentSchema: value }),
|
||||||
|
|
||||||
|
currentBlock: null,
|
||||||
|
setCurrentBlock: value => set({ currentBlock: value }),
|
||||||
|
|
||||||
|
currentConstituenta: null,
|
||||||
|
setCurrentConstituenta: value => set({ currentConstituenta: value })
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Returns a selector function for Zustand based on variable type */
|
||||||
|
export function makeVariableSelector(variableType: PromptVariableType) {
|
||||||
|
switch (variableType) {
|
||||||
|
case PromptVariableType.OSS:
|
||||||
|
return (state: AIContextStore) => ({ currentOSS: state.currentOSS });
|
||||||
|
case PromptVariableType.SCHEMA:
|
||||||
|
return (state: AIContextStore) => ({ currentSchema: state.currentSchema });
|
||||||
|
case PromptVariableType.BLOCK:
|
||||||
|
return (state: AIContextStore) => ({ currentBlock: state.currentBlock });
|
||||||
|
case PromptVariableType.CONSTITUENTA:
|
||||||
|
return (state: AIContextStore) => ({ currentConstituenta: state.currentConstituenta });
|
||||||
|
default:
|
||||||
|
return () => ({});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Evaluates a prompt variable */
|
||||||
|
export function evaluatePromptVariable(variableType: PromptVariableType, context: Partial<AIContextStore>): string {
|
||||||
|
switch (variableType) {
|
||||||
|
case PromptVariableType.OSS:
|
||||||
|
return context.currentOSS?.title ?? '';
|
||||||
|
case PromptVariableType.SCHEMA:
|
||||||
|
return context.currentSchema ? generateSchemaPrompt(context.currentSchema) : '';
|
||||||
|
case PromptVariableType.BLOCK:
|
||||||
|
return context.currentBlock?.title ?? '';
|
||||||
|
case PromptVariableType.CONSTITUENTA:
|
||||||
|
return context.currentConstituenta?.alias ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Internals =========
|
||||||
|
function generateSchemaPrompt(schema: IRSForm): string {
|
||||||
|
let body = `Название концептуальной схемы: ${schema.title}\n`;
|
||||||
|
body += `[${schema.alias}] Описание: "${schema.description}"\n\n`;
|
||||||
|
body += 'Понятия:\n';
|
||||||
|
schema.items.forEach(item => {
|
||||||
|
body += `${item.alias} - "${labelCstTypification(item)}" - "${item.term_resolved}" - "${
|
||||||
|
item.definition_formal
|
||||||
|
}" - "${item.definition_resolved}" - "${item.convention}"\n`;
|
||||||
|
});
|
||||||
|
return body;
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { PromptVariableType } from '../models/prompting';
|
||||||
|
|
||||||
|
import { useAIStore } from './ai-context';
|
||||||
|
|
||||||
|
export function useAvailableVariables(): PromptVariableType[] {
|
||||||
|
const hasCurrentOSS = useAIStore(state => !!state.currentOSS);
|
||||||
|
const hasCurrentSchema = useAIStore(state => !!state.currentSchema);
|
||||||
|
const hasCurrentBlock = useAIStore(state => !!state.currentBlock);
|
||||||
|
const hasCurrentConstituenta = useAIStore(state => !!state.currentConstituenta);
|
||||||
|
|
||||||
|
return [
|
||||||
|
...(hasCurrentOSS ? [PromptVariableType.OSS] : []),
|
||||||
|
...(hasCurrentSchema ? [PromptVariableType.SCHEMA] : []),
|
||||||
|
...(hasCurrentBlock ? [PromptVariableType.BLOCK] : []),
|
||||||
|
...(hasCurrentConstituenta ? [PromptVariableType.CONSTITUENTA] : [])
|
||||||
|
];
|
||||||
|
}
|
|
@ -1,10 +1,9 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { limits } from '@/utils/constants';
|
||||||
import { errorMsg } from '@/utils/labels';
|
import { errorMsg } from '@/utils/labels';
|
||||||
|
|
||||||
/**
|
/** Represents CurrentUser information. */
|
||||||
* Represents CurrentUser information.
|
|
||||||
*/
|
|
||||||
export interface ICurrentUser {
|
export interface ICurrentUser {
|
||||||
id: number | null;
|
id: number | null;
|
||||||
username: string;
|
username: string;
|
||||||
|
@ -12,27 +11,40 @@ export interface ICurrentUser {
|
||||||
editor: number[];
|
editor: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Represents login data, used to authenticate users. */
|
||||||
* Represents login data, used to authenticate users.
|
|
||||||
*/
|
|
||||||
export const schemaUserLogin = z.strictObject({
|
|
||||||
username: z.string().nonempty(errorMsg.requiredField),
|
|
||||||
password: z.string().nonempty(errorMsg.requiredField)
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents login data, used to authenticate users.
|
|
||||||
*/
|
|
||||||
export type IUserLoginDTO = z.infer<typeof schemaUserLogin>;
|
export type IUserLoginDTO = z.infer<typeof schemaUserLogin>;
|
||||||
|
|
||||||
/**
|
/** Represents data needed to update password for current user. */
|
||||||
* Represents data needed to update password for current user.
|
export type IChangePasswordDTO = z.infer<typeof schemaChangePassword>;
|
||||||
*/
|
|
||||||
|
/** Represents password reset request data. */
|
||||||
|
export interface IRequestPasswordDTO {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Represents password reset data. */
|
||||||
|
export interface IResetPasswordDTO {
|
||||||
|
password: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Represents password token data. */
|
||||||
|
export interface IPasswordTokenDTO {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========= SCHEMAS ========
|
||||||
|
|
||||||
|
export const schemaUserLogin = z.strictObject({
|
||||||
|
username: z.string().max(limits.len_alias, errorMsg.aliasLength).nonempty(errorMsg.requiredField),
|
||||||
|
password: z.string().max(limits.len_alias, errorMsg.aliasLength).nonempty(errorMsg.requiredField)
|
||||||
|
});
|
||||||
|
|
||||||
export const schemaChangePassword = z
|
export const schemaChangePassword = z
|
||||||
.object({
|
.object({
|
||||||
old_password: z.string().nonempty(errorMsg.requiredField),
|
old_password: z.string().max(limits.len_alias, errorMsg.aliasLength).nonempty(errorMsg.requiredField),
|
||||||
new_password: z.string().nonempty(errorMsg.requiredField),
|
new_password: z.string().max(limits.len_alias, errorMsg.aliasLength).nonempty(errorMsg.requiredField),
|
||||||
new_password2: z.string().nonempty(errorMsg.requiredField)
|
new_password2: z.string().max(limits.len_alias, errorMsg.aliasLength).nonempty(errorMsg.requiredField)
|
||||||
})
|
})
|
||||||
.refine(schema => schema.new_password === schema.new_password2, {
|
.refine(schema => schema.new_password === schema.new_password2, {
|
||||||
path: ['new_password2'],
|
path: ['new_password2'],
|
||||||
|
@ -42,30 +54,3 @@ export const schemaChangePassword = z
|
||||||
path: ['new_password'],
|
path: ['new_password'],
|
||||||
message: errorMsg.passwordsSame
|
message: errorMsg.passwordsSame
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents data needed to update password for current user.
|
|
||||||
*/
|
|
||||||
export type IChangePasswordDTO = z.infer<typeof schemaChangePassword>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents password reset request data.
|
|
||||||
*/
|
|
||||||
export interface IRequestPasswordDTO {
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents password reset data.
|
|
||||||
*/
|
|
||||||
export interface IResetPasswordDTO {
|
|
||||||
password: string;
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents password token data.
|
|
||||||
*/
|
|
||||||
export interface IPasswordTokenDTO {
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ import {
|
||||||
IconPin,
|
IconPin,
|
||||||
IconUser2
|
IconUser2
|
||||||
} from '@/components/icons';
|
} from '@/components/icons';
|
||||||
|
import { isMac } from '@/utils/utils';
|
||||||
|
|
||||||
import { Subtopics } from '../components/subtopics';
|
import { Subtopics } from '../components/subtopics';
|
||||||
import { HelpTopic } from '../models/help-topic';
|
import { HelpTopic } from '../models/help-topic';
|
||||||
|
@ -33,7 +34,7 @@ export function HelpInterface() {
|
||||||
<h2>Навигация и настройки</h2>
|
<h2>Навигация и настройки</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<kbd>Ctrl + клик</kbd> на объект навигации откроет новую вкладку
|
<kbd>{isMac() ? 'Cmd + клик' : 'Ctrl + клик'}</kbd> на объект навигации откроет новую вкладку
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<IconPin size='1.25rem' className='inline-icon' /> навигационную панель можно скрыть с помощью кнопки в правом
|
<IconPin size='1.25rem' className='inline-icon' /> навигационную панель можно скрыть с помощью кнопки в правом
|
||||||
|
|
|
@ -5,8 +5,7 @@ import {
|
||||||
IconFolderEdit,
|
IconFolderEdit,
|
||||||
IconFolderEmpty,
|
IconFolderEmpty,
|
||||||
IconFolderOpened,
|
IconFolderOpened,
|
||||||
IconFolderSearch,
|
IconLeftClose,
|
||||||
IconFolderTree,
|
|
||||||
IconOSS,
|
IconOSS,
|
||||||
IconRSForm,
|
IconRSForm,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
|
@ -16,6 +15,7 @@ import {
|
||||||
IconSubfolders,
|
IconSubfolders,
|
||||||
IconUserSearch
|
IconUserSearch
|
||||||
} from '@/components/icons';
|
} from '@/components/icons';
|
||||||
|
import { isMac } from '@/utils/utils';
|
||||||
|
|
||||||
import { LinkTopic } from '../../components/link-topic';
|
import { LinkTopic } from '../../components/link-topic';
|
||||||
import { HelpTopic } from '../../models/help-topic';
|
import { HelpTopic } from '../../models/help-topic';
|
||||||
|
@ -39,34 +39,31 @@ export function HelpLibrary() {
|
||||||
<kbd>клик</kbd> по строке - переход к редактированию схемы
|
<kbd>клик</kbd> по строке - переход к редактированию схемы
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<kbd>Ctrl + клик</kbd> по строке откроет схему в новой вкладке
|
<kbd>{isMac() ? 'Cmd + клик' : 'Ctrl + клик'}</kbd> по строке откроет схему в новой вкладке
|
||||||
</li>
|
</li>
|
||||||
<li>Фильтры атрибутов три позиции: да/нет/не применять</li>
|
<li>Фильтры атрибутов три позиции: да/нет/не применять</li>
|
||||||
<li>
|
<li>
|
||||||
<IconShow size='1rem' className='inline-icon' /> фильтры атрибутов применяются по клику
|
<IconShow size='1rem' className='inline-icon' /> фильтры атрибутов применяются по клику
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<IconSortAsc size='1rem' className='inline-icon' />
|
|
||||||
<IconSortDesc size='1rem' className='inline-icon' /> сортировка по клику на заголовок таблицы
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
<IconUserSearch size='1rem' className='inline-icon' /> фильтр по пользователю
|
<IconUserSearch size='1rem' className='inline-icon' /> фильтр по пользователю
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<IconSearch size='1rem' className='inline-icon' /> фильтр по названию и шифру
|
<IconSearch size='1rem' className='inline-icon' /> фильтр по названию и сокращению
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<IconFolderSearch size='1rem' className='inline-icon' /> фильтр по расположению
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<IconFilterReset size='1rem' className='inline-icon' /> сбросить фильтры
|
<IconFilterReset size='1rem' className='inline-icon' /> сбросить фильтры
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<IconFolderTree size='1rem' className='inline-icon' /> переключение между Проводник и Таблица
|
<IconLeftClose size='1rem' className='inline-icon' /> отображение Проводника
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<IconSortAsc size='1rem' className='inline-icon' />
|
||||||
|
<IconSortDesc size='1rem' className='inline-icon' /> сортировка по клику на заголовок таблицы
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>Режим: Проводник</h2>
|
<h2>Проводник</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<IconFolderEdit size='1rem' className='inline-icon' /> переименовать выбранную
|
<IconFolderEdit size='1rem' className='inline-icon' /> переименовать выбранную
|
||||||
|
@ -78,7 +75,11 @@ export function HelpLibrary() {
|
||||||
<kbd>клик</kbd> по папке отображает справа схемы в ней
|
<kbd>клик</kbd> по папке отображает справа схемы в ней
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<kbd>Ctrl + клик по папке копирует путь в буфер обмена</kbd>
|
<kbd>
|
||||||
|
{isMac()
|
||||||
|
? 'Cmd + клик по папке копирует путь в буфер обмена'
|
||||||
|
: 'Ctrl + клик по папке копирует путь в буфер обмена'}
|
||||||
|
</kbd>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<kbd>клик</kbd> по иконке сворачивает/разворачивает вложенные
|
<kbd>клик</kbd> по иконке сворачивает/разворачивает вложенные
|
||||||
|
|
|
@ -41,7 +41,7 @@ export function HelpOssGraph() {
|
||||||
<IconFitImage className='inline-icon' /> Вписать в экран
|
<IconFitImage className='inline-icon' /> Вписать в экран
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<IconLeftOpen className='inline-icon' /> Панель связанной КС
|
<IconLeftOpen className='inline-icon' /> Панель содержания
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<IconSettings className='inline-icon' /> Диалог настроек
|
<IconSettings className='inline-icon' /> Диалог настроек
|
||||||
|
|
|
@ -8,6 +8,7 @@ import {
|
||||||
IconPublic,
|
IconPublic,
|
||||||
IconSave
|
IconSave
|
||||||
} from '@/components/icons';
|
} from '@/components/icons';
|
||||||
|
import { isMac } from '@/utils/utils';
|
||||||
|
|
||||||
import { LinkTopic } from '../../components/link-topic';
|
import { LinkTopic } from '../../components/link-topic';
|
||||||
import { HelpTopic } from '../../models/help-topic';
|
import { HelpTopic } from '../../models/help-topic';
|
||||||
|
@ -34,7 +35,7 @@ export function HelpRSCard() {
|
||||||
<IconOSS className='inline-icon' /> переход к связанной <LinkTopic text='ОСС' topic={HelpTopic.CC_OSS} />
|
<IconOSS className='inline-icon' /> переход к связанной <LinkTopic text='ОСС' topic={HelpTopic.CC_OSS} />
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<IconSave className='inline-icon' /> сохранить изменения: <kbd>Ctrl + S</kbd>
|
<IconSave className='inline-icon' /> сохранить изменения: <kbd>{isMac() ? 'Cmd + S' : 'Ctrl + S'}</kbd>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<IconEditor className='inline-icon' /> Редактор обладает правом редактирования
|
<IconEditor className='inline-icon' /> Редактор обладает правом редактирования
|
||||||
|
|
|
@ -18,6 +18,7 @@ import {
|
||||||
IconTree,
|
IconTree,
|
||||||
IconTypeGraph
|
IconTypeGraph
|
||||||
} from '@/components/icons';
|
} from '@/components/icons';
|
||||||
|
import { isMac } from '@/utils/utils';
|
||||||
|
|
||||||
import { LinkTopic } from '../../components/link-topic';
|
import { LinkTopic } from '../../components/link-topic';
|
||||||
import { HelpTopic } from '../../models/help-topic';
|
import { HelpTopic } from '../../models/help-topic';
|
||||||
|
@ -40,7 +41,7 @@ export function HelpRSEditor() {
|
||||||
<IconLeftOpen className='inline-icon' /> список конституент
|
<IconLeftOpen className='inline-icon' /> список конституент
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<IconSave className='inline-icon' /> сохранить: <kbd>Ctrl + S</kbd>
|
<IconSave className='inline-icon' /> сохранить: <kbd>{isMac() ? 'Cmd + S' : 'Ctrl + S'}</kbd>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<IconReset className='inline-icon' /> сбросить изменения
|
<IconReset className='inline-icon' /> сбросить изменения
|
||||||
|
@ -104,7 +105,7 @@ export function HelpRSEditor() {
|
||||||
<LinkTopic text='дерева разбора' topic={HelpTopic.UI_FORMULA_TREE} />
|
<LinkTopic text='дерева разбора' topic={HelpTopic.UI_FORMULA_TREE} />
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<kbd>Ctrl + Пробел</kbd> вставка незанятого имени / замена проекции
|
<kbd>{isMac() ? 'Cmd + Пробел' : 'Ctrl + Пробел'}</kbd> вставка незанятого имени / замена проекции
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
@ -116,7 +117,7 @@ export function HelpRSEditor() {
|
||||||
<LinkTopic text='Термина' topic={HelpTopic.CC_CONSTITUENTA} />
|
<LinkTopic text='Термина' topic={HelpTopic.CC_CONSTITUENTA} />
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<kbd>Ctrl + Пробел</kbd> открывает редактирование отсылок
|
<kbd>{isMac() ? 'Cmd + Пробел' : 'Ctrl + Пробел'}</kbd> открывает редактирование отсылок
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,10 +2,12 @@ import { TextURL } from '@/components/control';
|
||||||
|
|
||||||
export function NotFoundPage() {
|
export function NotFoundPage() {
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col items-center px-6 py-6'>
|
<div className='flex flex-col items-center px-6 py-3'>
|
||||||
<h1 className='mb-3'>Ошибка 404 – Страница не найдена</h1>
|
<h1>Ошибка 404 – Страница не найдена</h1>
|
||||||
<p className='py-3'>Данная страница не существует или запрашиваемый объект отсутствует в базе данных</p>
|
<p className='py-3'>Данная страница не существует или запрашиваемый объект отсутствует в базе данных</p>
|
||||||
|
<p className='-mt-4'>
|
||||||
<TextURL href='/' text='Вернуться на Портал' />
|
<TextURL href='/' text='Вернуться на Портал' />
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -118,10 +118,10 @@ export const libraryApi = {
|
||||||
successMessage: infoMsg.itemDestroyed
|
successMessage: infoMsg.itemDestroyed
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
cloneItem: (data: ICloneLibraryItemDTO) =>
|
cloneItem: ({ itemID, data }: { itemID: number; data: ICloneLibraryItemDTO }) =>
|
||||||
axiosPost<ICloneLibraryItemDTO, IRSFormDTO>({
|
axiosPost<ICloneLibraryItemDTO, IRSFormDTO>({
|
||||||
schema: schemaRSForm,
|
schema: schemaRSForm,
|
||||||
endpoint: `/api/library/${data.id}/clone`,
|
endpoint: `/api/library/${itemID}/clone`,
|
||||||
request: {
|
request: {
|
||||||
data: data,
|
data: data,
|
||||||
successMessage: newSchema => infoMsg.cloneComplete(newSchema.alias)
|
successMessage: newSchema => infoMsg.cloneComplete(newSchema.alias)
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { limits } from '@/utils/constants';
|
||||||
import { errorMsg } from '@/utils/labels';
|
import { errorMsg } from '@/utils/labels';
|
||||||
|
|
||||||
import { validateLocation } from '../models/library-api';
|
import { validateLocation } from '../models/library-api';
|
||||||
|
@ -61,9 +62,11 @@ export const schemaAccessPolicy = z.enum(Object.values(AccessPolicy) as [AccessP
|
||||||
export const schemaLibraryItem = z.strictObject({
|
export const schemaLibraryItem = z.strictObject({
|
||||||
id: z.coerce.number(),
|
id: z.coerce.number(),
|
||||||
item_type: schemaLibraryItemType,
|
item_type: schemaLibraryItemType,
|
||||||
title: z.string(),
|
|
||||||
alias: z.string().nonempty(),
|
alias: z.string().nonempty(),
|
||||||
|
title: z.string(),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
|
|
||||||
visible: z.boolean(),
|
visible: z.boolean(),
|
||||||
read_only: z.boolean(),
|
read_only: z.boolean(),
|
||||||
location: z.string(),
|
location: z.string(),
|
||||||
|
@ -76,57 +79,50 @@ export const schemaLibraryItem = z.strictObject({
|
||||||
|
|
||||||
export const schemaLibraryItemArray = z.array(schemaLibraryItem);
|
export const schemaLibraryItemArray = z.array(schemaLibraryItem);
|
||||||
|
|
||||||
export const schemaCloneLibraryItem = schemaLibraryItem
|
const schemaInputLibraryItem = schemaLibraryItem
|
||||||
.pick({
|
.pick({
|
||||||
id: true,
|
|
||||||
item_type: true,
|
item_type: true,
|
||||||
title: true,
|
|
||||||
alias: true,
|
|
||||||
description: true,
|
|
||||||
visible: true,
|
visible: true,
|
||||||
read_only: true,
|
read_only: true,
|
||||||
location: true,
|
|
||||||
access_policy: true
|
access_policy: true
|
||||||
})
|
})
|
||||||
.extend({
|
.extend({
|
||||||
title: z.string().nonempty(errorMsg.requiredField),
|
alias: z.string().max(limits.len_alias, errorMsg.aliasLength).nonempty(errorMsg.requiredField),
|
||||||
alias: z.string().nonempty(errorMsg.requiredField),
|
title: z.string().max(limits.len_title, errorMsg.titleLength).nonempty(errorMsg.requiredField),
|
||||||
location: z.string().refine(data => validateLocation(data), { message: errorMsg.invalidLocation }),
|
description: z.string().max(limits.len_description, errorMsg.descriptionLength),
|
||||||
|
location: z.string().refine(data => validateLocation(data), { message: errorMsg.invalidLocation })
|
||||||
items: z.array(z.number())
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schemaCreateLibraryItem = z
|
export const schemaCloneLibraryItem = z.strictObject({
|
||||||
.object({
|
items: z.array(z.number()),
|
||||||
item_type: schemaLibraryItemType,
|
item_data: schemaInputLibraryItem.omit({ item_type: true, read_only: true })
|
||||||
title: z.string().optional(),
|
});
|
||||||
alias: z.string().optional(),
|
|
||||||
description: z.string(),
|
export const schemaCreateLibraryItem = schemaInputLibraryItem
|
||||||
visible: z.boolean(),
|
.extend({
|
||||||
read_only: z.boolean(),
|
alias: z.string().max(limits.len_alias, errorMsg.aliasLength).optional(),
|
||||||
location: z.string().refine(data => validateLocation(data), { message: errorMsg.invalidLocation }),
|
title: z.string().max(limits.len_title, errorMsg.titleLength).optional(),
|
||||||
access_policy: schemaAccessPolicy,
|
description: z.string().max(limits.len_description, errorMsg.descriptionLength).optional(),
|
||||||
|
|
||||||
file: z.instanceof(File).optional(),
|
file: z.instanceof(File).optional(),
|
||||||
fileName: z.string().optional()
|
fileName: z.string().optional()
|
||||||
})
|
})
|
||||||
.refine(data => !!data.file || !!data.title, {
|
|
||||||
path: ['title'],
|
|
||||||
message: errorMsg.requiredField
|
|
||||||
})
|
|
||||||
.refine(data => !!data.file || !!data.alias, {
|
.refine(data => !!data.file || !!data.alias, {
|
||||||
path: ['alias'],
|
path: ['alias'],
|
||||||
message: errorMsg.requiredField
|
message: errorMsg.requiredField
|
||||||
|
})
|
||||||
|
.refine(data => !!data.file || !!data.title, {
|
||||||
|
path: ['title'],
|
||||||
|
message: errorMsg.requiredField
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schemaUpdateLibraryItem = z.strictObject({
|
export const schemaUpdateLibraryItem = schemaInputLibraryItem
|
||||||
id: z.number(),
|
.omit({
|
||||||
item_type: schemaLibraryItemType,
|
location: true,
|
||||||
title: z.string().nonempty(errorMsg.requiredField),
|
access_policy: true
|
||||||
alias: z.string().nonempty(errorMsg.requiredField),
|
})
|
||||||
description: z.string(),
|
.extend({
|
||||||
visible: z.boolean(),
|
id: z.number()
|
||||||
read_only: z.boolean()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schemaVersionInfo = z.strictObject({
|
export const schemaVersionInfo = z.strictObject({
|
||||||
|
@ -136,18 +132,19 @@ export const schemaVersionInfo = z.strictObject({
|
||||||
time_create: z.string().datetime({ offset: true })
|
time_create: z.string().datetime({ offset: true })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const schemaVersionInput = z.strictObject({
|
||||||
|
version: z.string().max(limits.len_alias, errorMsg.aliasLength).nonempty(errorMsg.requiredField),
|
||||||
|
description: z.string().max(limits.len_description, errorMsg.descriptionLength)
|
||||||
|
});
|
||||||
|
|
||||||
export const schemaVersionExInfo = schemaVersionInfo.extend({
|
export const schemaVersionExInfo = schemaVersionInfo.extend({
|
||||||
item: z.number()
|
item: z.number()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schemaUpdateVersion = z.strictObject({
|
export const schemaUpdateVersion = schemaVersionInput.extend({
|
||||||
id: z.number(),
|
id: z.number()
|
||||||
version: z.string().nonempty(errorMsg.requiredField),
|
|
||||||
description: z.string()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schemaCreateVersion = z.strictObject({
|
export const schemaCreateVersion = schemaVersionInput.extend({
|
||||||
version: z.string(),
|
|
||||||
description: z.string(),
|
|
||||||
items: z.array(z.number())
|
items: z.array(z.number())
|
||||||
});
|
});
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user