Compare commits

...

38 Commits

Author SHA1 Message Date
Ivan
679eefcd38 M: Add restart after rebuild
Some checks failed
Backend CI / build (3.12) (push) Has been cancelled
Frontend CI / build (22.x) (push) Has been cancelled
Backend CI / notify-failure (push) Has been cancelled
Frontend CI / notify-failure (push) Has been cancelled
2025-07-17 19:33:22 +03:00
Ivan
94ea516256 M: Fix minor UI issues 2025-07-17 19:27:32 +03:00
Ivan
2cb7c44f3a B: Fix cyclic block hierarchy 2025-07-17 19:17:08 +03:00
Ivan
854d32aea1 F: Improve data validation for user inputs and backend serializers 2025-07-17 19:02:58 +03:00
Ivan
86d81d7652 B: Fix access for unauth users 2025-07-16 10:52:54 +03:00
Ivan
2a3f413315 F: Implementing generator pt1 2025-07-16 10:43:58 +03:00
Ivan
56f1bcacad F: Implementing PromptEdit pt3 2025-07-15 21:59:38 +03:00
Ivan
56652095af F: Implementing PromptEdit pt2 2025-07-15 20:12:00 +03:00
Ivan
12c202adff F: Implementing PromptEdit pt1 2025-07-15 13:30:41 +03:00
Ivan
2beed1c1c6 F: Implementing prompt UI pt2 2025-07-14 22:31:20 +03:00
Ivan
c980ebab5a F: Implementing prompt UI pt1 2025-07-14 19:05:50 +03:00
Ivan
1d11bd4ab5 F: Implement backend hooks for frontend 2025-07-14 15:47:14 +03:00
Ivan
e4411c2c78 F: Implement backend for prompts 2025-07-13 17:58:32 +03:00
Ivan
1fda7c79c3 R: Replace switch statements with records 2025-07-12 18:28:38 +03:00
Ivan
fc32b2637c F: Introducing AI UI pt1 2025-07-11 13:33:10 +03:00
Ivan
a03c5d7fe8 F: Introduce AI UI 2025-07-10 20:00:58 +03:00
Ivan
f61a8636f6 B: Fix not-found page 2025-07-10 19:50:35 +03:00
Ivan
3bca1a8921 M: Minor UI fixes 2025-07-10 17:08:43 +03:00
Ivan
073cd5412f F: Implement different dialogs for operation creation 2025-07-10 16:32:31 +03:00
Ivan
4f47c736ce x 2025-07-09 17:58:09 +03:00
Ivan
7dc0088bd2 F: Implement TermGraph view 2025-07-09 17:24:20 +03:00
Ivan
09075f2416 R: Refactoring tg structure 2025-07-09 12:43:00 +03:00
Ivan
315c0f847e R: Refactor term-graph preparing to extract shared components 2025-07-09 12:15:21 +03:00
Ivan
d901094d8e F: Implement Constituenta editing from OSS 2025-07-08 20:55:24 +03:00
Ivan
3feadb6f06 F: Improve panel UI 2025-07-08 19:19:37 +03:00
Ivan
d46fa536e6 F: Rework constituenta editing endpoints 2025-07-08 18:27:52 +03:00
Ivan
087ec8cf56 M: Improve UI consistency for closed naviation 2025-07-08 13:21:12 +03:00
Ivan
68bde04dd1 update dependencies 2025-07-08 12:58:42 +03:00
Ivan
b62797205b R: Apply withPreventDefault utility 2025-07-08 12:37:08 +03:00
Ivan
022041881b F: Improve tooltips for Mac 2025-07-08 12:08:06 +03:00
Ivan
cac508451d M: Improve UI for creating new elements 2025-07-08 11:22:43 +03:00
Ivan
9d8405fc36 F: Remove unnecessary updates and fix UI 2025-07-07 17:32:35 +03:00
Ivan
ae22e9b9f7 F: Add block stats to side panel 2025-07-07 15:01:25 +03:00
Ivan
d08d3432bc M: Add pulse to remove item icon 2025-07-07 13:28:54 +03:00
Ivan
1260f159c9 M: Multiple minor UI fixes 2025-07-03 17:24:40 +03:00
Ivan
c361047caf M: Disable selection for control elements 2025-07-03 11:58:13 +03:00
Ivan
161a51fc45 F: Improve folders UI 2025-07-03 11:39:51 +03:00
Ivan
cd62ad574f z 2025-07-02 20:52:51 +03:00
217 changed files with 5721 additions and 2696 deletions

View File

@ -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,

View File

@ -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()

View File

@ -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'))

View File

@ -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()

View File

@ -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)

View File

@ -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()

View File

@ -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,

View File

@ -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()

View File

@ -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,

View File

@ -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()

View File

@ -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}')

View File

@ -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)

View File

@ -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)

View File

@ -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
} }
) )

View 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')

View 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'

View 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='Владелец')),
],
),
]

View 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}'

View File

@ -0,0 +1,3 @@
''' Django: Models for AI Prompts. '''
from .PromptTemplate import PromptTemplate

View File

@ -0,0 +1,2 @@
''' Serializers for persistent data manipulation (AI Prompts). '''
from .data_access import PromptTemplateListSerializer, PromptTemplateSerializer

View 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']

View File

@ -0,0 +1,2 @@
''' Tests. '''
from .t_prompts import *

View 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)

View 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

View File

@ -0,0 +1,2 @@
''' REST API: Endpoint processors for AI Prompts. '''
from .prompts import PromptTemplateViewSet

View 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)

View File

@ -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)

View File

@ -16,7 +16,6 @@ from .data_access import (
CstInfoSerializer, CstInfoSerializer,
CstListSerializer, CstListSerializer,
CstMoveSerializer, CstMoveSerializer,
CstRenameSerializer,
CstSubstituteSerializer, CstSubstituteSerializer,
CstTargetSerializer, CstTargetSerializer,
CstUpdateSerializer, CstUpdateSerializer,

View File

@ -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()

View File

@ -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

View File

@ -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()

View File

@ -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()

View File

@ -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):

View File

@ -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',

View File

@ -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])

View File

@ -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',

View File

@ -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()),

View File

@ -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

View File

@ -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

View File

@ -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 'Шаблон не найден.'

View 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)

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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}

View File

@ -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 />;
} }
}; };

View 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>
);
}

View File

@ -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 })}

View File

@ -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>
); );
} }

View File

@ -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>

View File

@ -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 />
} }
] ]
} }

View File

@ -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) =>

View File

@ -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: {

View File

@ -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
)} )}

View File

@ -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);
} }

View File

@ -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';

View File

@ -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}

View File

@ -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);

View 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;

View 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();

View File

@ -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());
}

View File

@ -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
};
}

View File

@ -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
};
}

View File

@ -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;
};

View File

@ -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));
}

View File

@ -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
};
}

View File

@ -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>
);
}

View File

@ -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'} />;
}
}

View File

@ -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>
);
}

View File

@ -0,0 +1 @@
export * from './dlg-ai-prompt';

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View 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}`;
}

View File

@ -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']);
});
});

View 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;
}

View 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];

View File

@ -0,0 +1 @@
export { PromptTemplatesPage as Component } from './prompt-templates-page';

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -0,0 +1 @@
export { TabEditTemplate } from './tab-edit-template';

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View 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;
}

View File

@ -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] : [])
];
}

View File

@ -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;
}

View File

@ -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' /> навигационную панель можно скрыть с помощью кнопки в правом

View File

@ -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> по иконке сворачивает/разворачивает вложенные

View File

@ -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' /> Диалог настроек

View File

@ -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' /> Редактор обладает правом редактирования

View File

@ -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>

View File

@ -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>
); );
} }

View File

@ -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)

View File

@ -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,58 +79,51 @@ 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({
id: z.coerce.number(), id: z.coerce.number(),
@ -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