Implement RSForm upload and fix bugs

This commit is contained in:
IRBorisov 2023-07-27 22:04:25 +03:00
parent 467f45b0c8
commit 9dd3155319
26 changed files with 508 additions and 176 deletions

View File

@ -9,4 +9,4 @@ $exclude = '*/venv/*,*/tests/*,*/migrations/*,*__init__.py,manage.py,apps.py,url
& $coverageExec report & $coverageExec report
& $coverageExec html & $coverageExec html
Start-Process "file:///D:/DEV/!WORK/Concept-Web/rsconcept/backend/htmlcov/index.html" Start-Process "file:///$PSScriptRoot\backend\htmlcov\index.html"

View File

@ -11,7 +11,7 @@ def load_initial_schemas(apps, schema_editor):
for subdir, dirs, files in os.walk(rootdir): for subdir, dirs, files in os.walk(rootdir):
for file in files: for file in files:
data = utils.read_trs(os.path.join(subdir, file)) data = utils.read_trs(os.path.join(subdir, file))
RSForm.import_json(None, data) RSForm.create_from_trs(None, data)
def load_initial_users(apps, schema_editor): def load_initial_users(apps, schema_editor):

View File

@ -98,7 +98,7 @@ class RSForm(models.Model):
''' Insert new constituenta at last position ''' ''' Insert new constituenta at last position '''
position = 1 position = 1
if self.constituents().exists(): if self.constituents().exists():
position += self.constituents().only('order').aggregate(models.Max('order'))['order__max'] position += self.constituents().count()
result = Constituenta.objects.create( result = Constituenta.objects.create(
schema=self, schema=self,
order=position, order=position,
@ -118,7 +118,7 @@ class RSForm(models.Model):
count_bot = 0 count_bot = 0
size = len(listCst) size = len(listCst)
update_list = [] update_list = []
for cst in self.constituents(): for cst in self.constituents().only('id', 'order').order_by('order'):
if cst not in listCst: if cst not in listCst:
if count_top + 1 < target: if count_top + 1 < target:
cst.order = count_top + 1 cst.order = count_top + 1
@ -142,9 +142,38 @@ class RSForm(models.Model):
self._update_from_core() self._update_from_core()
self.save() self.save()
@transaction.atomic
def load_trs(self, data: dict, sync_metadata: bool, skip_update: bool):
if sync_metadata:
self.title = data.get('title', 'Без названия')
self.alias = data.get('alias', '')
self.comment = data.get('comment', '')
order = 1
prev_constituents = self.constituents()
loaded_ids = set()
for cst_data in data['items']:
uid = int(cst_data['entityUID'])
if prev_constituents.filter(pk=uid).exists():
cst: Constituenta = prev_constituents.get(pk=uid)
cst.order = order
cst.load_trs(cst_data)
cst.save()
else:
cst = Constituenta.create_from_trs(cst_data, self, order)
cst.save()
uid = cst.id
loaded_ids.add(uid)
order += 1
for prev_cst in prev_constituents:
if prev_cst.id not in loaded_ids:
prev_cst.delete()
if not skip_update:
self._update_from_core()
self.save()
@staticmethod @staticmethod
@transaction.atomic @transaction.atomic
def import_json(owner: User, data: dict, is_common: bool = True) -> 'RSForm': def create_from_trs(owner: User, data: dict, is_common: bool = True) -> 'RSForm':
schema = RSForm.objects.create( schema = RSForm.objects.create(
title=data.get('title', 'Без названия'), title=data.get('title', 'Без названия'),
owner=owner, owner=owner,
@ -152,15 +181,15 @@ class RSForm(models.Model):
comment=data.get('comment', ''), comment=data.get('comment', ''),
is_common=is_common is_common=is_common
) )
schema._create_cst_from_json(data['items']) schema._create_items_from_trs(data['items'])
return schema return schema
def to_json(self) -> str: def to_trs(self) -> str:
''' Generate JSON string containing all data from RSForm ''' ''' Generate JSON string containing all data from RSForm '''
result = self._prepare_json_rsform() result = self._prepare_json_rsform()
items = self.constituents().order_by('order') items: list['Constituenta'] = self.constituents().order_by('order')
for cst in items: for cst in items:
result['items'].append(cst.to_json()) result['items'].append(cst.to_trs())
return result return result
def __str__(self): def __str__(self):
@ -178,8 +207,9 @@ class RSForm(models.Model):
'items': [] 'items': []
} }
@transaction.atomic
def _update_from_core(self) -> dict: def _update_from_core(self) -> dict:
checked = json.loads(pyconcept.check_schema(json.dumps(self.to_json()))) checked = json.loads(pyconcept.check_schema(json.dumps(self.to_trs())))
update_list = self.constituents().only('id', 'order') update_list = self.constituents().only('id', 'order')
if (len(checked['items']) != update_list.count()): if (len(checked['items']) != update_list.count()):
raise ValidationError raise ValidationError
@ -194,10 +224,12 @@ class RSForm(models.Model):
Constituenta.objects.bulk_update(update_list, ['order']) Constituenta.objects.bulk_update(update_list, ['order'])
return checked return checked
def _create_cst_from_json(self, items): @transaction.atomic
def _create_items_from_trs(self, items):
order = 1 order = 1
for cst in items: for cst in items:
Constituenta.import_json(cst, self, order) object = Constituenta.create_from_trs(cst, self, order)
object.save()
order += 1 order += 1
@ -270,28 +302,43 @@ class Constituenta(models.Model):
return self.alias return self.alias
@staticmethod @staticmethod
def import_json(data: dict, schema: RSForm, order: int) -> 'Constituenta': def create_from_trs(data: dict, schema: RSForm, order: int) -> 'Constituenta':
''' Create constituenta from TRS json '''
cst = Constituenta( cst = Constituenta(
alias=data['alias'], alias=data['alias'],
schema=schema, schema=schema,
order=order, order=order,
cst_type=data['cstType'], cst_type=data['cstType'],
convention=data.get('convention', 'Без названия')
) )
if 'definition' in data: cst._load_texts(data)
if 'formal' in data['definition']:
cst.definition_formal = data['definition']['formal']
if 'text' in data['definition']:
cst.definition_raw = data['definition']['text'].get('raw', '')
cst.definition_resolved = data['definition']['text'].get('resolved', '')
if 'term' in data:
cst.term_raw = data['term'].get('raw', '')
cst.term_resolved = data['term'].get('resolved', '')
cst.term_forms = data['term'].get('forms', [])
cst.save()
return cst return cst
def to_json(self) -> str: def load_trs(self, data: dict):
''' Load data from TRS json '''
self.alias = data['alias']
self.cst_type = data['cstType']
self._load_texts(data)
def _load_texts(self, data: dict):
self.convention = data.get('convention', '')
if 'definition' in data:
self.definition_formal = data['definition'].get('formal', '')
if 'text' in data['definition']:
self.definition_raw = data['definition']['text'].get('raw', '')
self.definition_resolved = data['definition']['text'].get('resolved', '')
else:
self.definition_raw = ''
self.definition_resolved = ''
if 'term' in data:
self.term_raw = data['term'].get('raw', '')
self.term_resolved = data['term'].get('resolved', '')
self.term_forms = data['term'].get('forms', [])
else:
self.term_raw = ''
self.term_resolved = ''
self.term_forms = []
def to_trs(self) -> str:
return { return {
'entityUID': self.id, 'entityUID': self.id,
'type': 'constituenta', 'type': 'constituenta',

View File

@ -20,6 +20,23 @@ class RSFormSerializer(serializers.ModelSerializer):
read_only_fields = ('owner', 'id') read_only_fields = ('owner', 'id')
class RSFormUploadSerializer(serializers.Serializer):
file = serializers.FileField()
load_metadata = serializers.BooleanField()
class RSFormContentsSerializer(serializers.ModelSerializer):
class Meta:
model = RSForm
def to_representation(self, instance: RSForm):
result = RSFormSerializer(instance).data
result['items'] = []
for cst in instance.constituents().order_by('order'):
result['items'].append(ConstituentaSerializer(cst).data)
return result
class ConstituentaSerializer(serializers.ModelSerializer): class ConstituentaSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Constituenta model = Constituenta
@ -79,7 +96,7 @@ class RSFormDetailsSerlializer(serializers.BaseSerializer):
model = RSForm model = RSForm
def to_representation(self, instance: RSForm): def to_representation(self, instance: RSForm):
trs = pyconcept.check_schema(json.dumps(instance.to_json())) trs = pyconcept.check_schema(json.dumps(instance.to_trs()))
trs = trs.replace('entityUID', 'id') trs = trs.replace('entityUID', 'id')
result = json.loads(trs) result = json.loads(trs)
result['id'] = instance.id result['id'] = instance.id

View File

@ -209,7 +209,7 @@ class TestRSForm(TestCase):
self.assertEqual(x1.order, 2) self.assertEqual(x1.order, 2)
self.assertEqual(x2.order, 1) self.assertEqual(x2.order, 1)
def test_to_json(self): def test_to_trs(self):
schema = RSForm.objects.create(title='Test', alias='KS1', comment='Test') schema = RSForm.objects.create(title='Test', alias='KS1', comment='Test')
x1 = schema.insert_at(4, 'X1', CstType.BASE) x1 = schema.insert_at(4, 'X1', CstType.BASE)
x2 = schema.insert_at(1, 'X2', CstType.BASE) x2 = schema.insert_at(1, 'X2', CstType.BASE)
@ -223,9 +223,9 @@ class TestRSForm(TestCase):
f'"term": {{"raw": "", "resolved": "", "forms": []}}, ' f'"term": {{"raw": "", "resolved": "", "forms": []}}, '
f'"definition": {{"formal": "", "text": {{"raw": "", "resolved": ""}}}}}}]}}' f'"definition": {{"formal": "", "text": {{"raw": "", "resolved": ""}}}}}}]}}'
) )
self.assertEqual(schema.to_json(), expected) self.assertEqual(schema.to_trs(), expected)
def test_import_json(self): def test_create_from_trs(self):
input = json.loads( input = json.loads(
'{"type": "rsform", "title": "Test", "alias": "KS1", ' '{"type": "rsform", "title": "Test", "alias": "KS1", '
'"comment": "Test", "items": ' '"comment": "Test", "items": '
@ -236,7 +236,7 @@ class TestRSForm(TestCase):
'"term": {"raw": "", "resolved": ""}, ' '"term": {"raw": "", "resolved": ""}, '
'"definition": {"formal": "", "text": {"raw": "", "resolved": ""}}}]}' '"definition": {"formal": "", "text": {"raw": "", "resolved": ""}}}]}'
) )
schema = RSForm.import_json(self.user1, input, False) schema = RSForm.create_from_trs(self.user1, input, False)
self.assertEqual(schema.owner, self.user1) self.assertEqual(schema.owner, self.user1)
self.assertEqual(schema.title, 'Test') self.assertEqual(schema.title, 'Test')
self.assertEqual(schema.alias, 'KS1') self.assertEqual(schema.alias, 'KS1')
@ -245,3 +245,28 @@ class TestRSForm(TestCase):
self.assertEqual(constituents.count(), 2) self.assertEqual(constituents.count(), 2)
self.assertEqual(constituents[0].alias, 'X1') self.assertEqual(constituents[0].alias, 'X1')
self.assertEqual(constituents[0].definition_formal, '123') self.assertEqual(constituents[0].definition_formal, '123')
def test_load_trs(self):
schema = RSForm.objects.create(title='Test', owner=self.user1, alias='КС1')
x2 = schema.insert_last('X2', CstType.BASE)
schema.insert_last('X3', CstType.BASE)
input = json.loads(
'{"title": "Test1", "alias": "KS1", '
'"comment": "Test", "items": '
'[{"entityUID": "' + str(x2.id) + '", "cstType": "basic", "alias": "X1", "convention": "test", '
'"term": {"raw": "t1", "resolved": "t2"}, '
'"definition": {"formal": "123", "text": {"raw": "t3", "resolved": "t4"}}}]}'
)
schema.load_trs(input, sync_metadata=True, skip_update=True)
x2.refresh_from_db()
self.assertEqual(schema.constituents().count(), 1)
self.assertEqual(schema.title, input['title'])
self.assertEqual(schema.alias, input['alias'])
self.assertEqual(schema.comment, input['comment'])
self.assertEqual(x2.alias, input['items'][0]['alias'])
self.assertEqual(x2.convention, input['items'][0]['convention'])
self.assertEqual(x2.term_raw, input['items'][0]['term']['raw'])
self.assertEqual(x2.term_resolved, input['items'][0]['term']['resolved'])
self.assertEqual(x2.definition_formal, input['items'][0]['definition']['formal'])
self.assertEqual(x2.definition_raw, input['items'][0]['definition']['text']['raw'])
self.assertEqual(x2.definition_resolved, input['items'][0]['definition']['text']['resolved'])

View File

@ -115,7 +115,6 @@ class TestRSFormViewset(APITestCase):
schema.insert_last(alias='X1', type=CstType.BASE) schema.insert_last(alias='X1', type=CstType.BASE)
response = self.client.get(f'/api/rsforms/{schema.id}/contents/') response = self.client.get(f'/api/rsforms/{schema.id}/contents/')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.data, schema.to_json())
def test_details(self): def test_details(self):
schema = RSForm.objects.create(title='Test') schema = RSForm.objects.create(title='Test')
@ -248,6 +247,57 @@ class TestRSFormViewset(APITestCase):
data=data, content_type='application/json') data=data, content_type='application/json')
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
def test_reset_aliases(self):
schema = self.rsform_owned
response = self.client.patch(f'/api/rsforms/{schema.id}/reset-aliases/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['id'], schema.id)
x2 = Constituenta.objects.create(schema=schema, alias='X2', cst_type='basic', order=1)
x1 = Constituenta.objects.create(schema=schema, alias='X1', cst_type='basic', order=2)
d11 = Constituenta.objects.create(schema=schema, alias='D11', cst_type='term', order=3)
response = self.client.patch(f'/api/rsforms/{schema.id}/reset-aliases/')
x1.refresh_from_db()
x2.refresh_from_db()
d11.refresh_from_db()
self.assertEqual(response.status_code, 200)
self.assertEqual(x2.order, 1)
self.assertEqual(x2.alias, 'X1')
self.assertEqual(x1.order, 2)
self.assertEqual(x1.alias, 'X2')
self.assertEqual(d11.order, 3)
self.assertEqual(d11.alias, 'D1')
response = self.client.patch(f'/api/rsforms/{schema.id}/reset-aliases/')
self.assertEqual(response.status_code, 200)
def test_load_trs(self):
schema = self.rsform_owned
schema.title = 'Testt11'
schema.save()
x1 = Constituenta.objects.create(schema=schema, alias='X1', cst_type='basic', order=1)
work_dir = os.path.dirname(os.path.abspath(__file__))
with open(f'{work_dir}/data/sample-rsform.trs', 'rb') as file:
data = {'file': file, 'load_metadata': False}
response = self.client.patch(f'/api/rsforms/{schema.id}/load-trs/', data=data, format='multipart')
schema.refresh_from_db()
self.assertEqual(response.status_code, 200)
self.assertEqual(schema.title, 'Testt11')
self.assertEqual(len(response.data['items']), 25)
self.assertEqual(schema.constituents().count(), 25)
self.assertFalse(Constituenta.objects.all().filter(pk=x1.id).exists())
def test_clone(self):
schema = self.rsform_owned
schema.title = 'Testt11'
schema.save()
x1 = Constituenta.objects.create(schema=schema, alias='X12', cst_type='basic', order=1)
data = json.dumps({'title': 'Title'})
response = self.client.post(f'/api/rsforms/{schema.id}/clone/', data=data, content_type='application/json')
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data['title'], 'Title')
self.assertEqual(response.data['items'][0]['alias'], x1.alias)
class TestFunctionalViews(APITestCase): class TestFunctionalViews(APITestCase):
def setUp(self): def setUp(self):

View File

@ -34,6 +34,9 @@ class RSFormViewSet(viewsets.ModelViewSet):
ordering_fields = ('owner', 'title', 'time_update') ordering_fields = ('owner', 'title', 'time_update')
ordering = ('-time_update') ordering = ('-time_update')
def _get_schema(self) -> models.RSForm:
return self.get_object()
def perform_create(self, serializer): def perform_create(self, serializer):
if not self.request.user.is_anonymous and 'owner' not in self.request.POST: if not self.request.user.is_anonymous and 'owner' not in self.request.POST:
return serializer.save(owner=self.request.user) return serializer.save(owner=self.request.user)
@ -41,10 +44,10 @@ class RSFormViewSet(viewsets.ModelViewSet):
return serializer.save() return serializer.save()
def get_permissions(self): def get_permissions(self):
if self.action in ['update', 'destroy', 'partial_update', if self.action in ['update', 'destroy', 'partial_update', 'load_trs',
'cst_create', 'cst_multidelete']: 'cst_create', 'cst_multidelete', 'reset_aliases']:
permission_classes = [utils.ObjectOwnerOrAdmin] permission_classes = [utils.ObjectOwnerOrAdmin]
elif self.action in ['create', 'claim']: elif self.action in ['create', 'claim', 'clone']:
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
else: else:
permission_classes = [permissions.AllowAny] permission_classes = [permissions.AllowAny]
@ -53,7 +56,7 @@ class RSFormViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['post'], url_path='cst-create') @action(detail=True, methods=['post'], url_path='cst-create')
def cst_create(self, request, pk): def cst_create(self, request, pk):
''' Create new constituenta ''' ''' Create new constituenta '''
schema: models.RSForm = self.get_object() schema = self._get_schema()
serializer = serializers.CstCreateSerializer(data=request.data) serializer = serializers.CstCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
if ('insert_after' in serializer.validated_data and serializer.validated_data['insert_after'] is not None): if ('insert_after' in serializer.validated_data and serializer.validated_data['insert_after'] is not None):
@ -74,7 +77,7 @@ class RSFormViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['patch'], url_path='cst-multidelete') @action(detail=True, methods=['patch'], url_path='cst-multidelete')
def cst_multidelete(self, request, pk): def cst_multidelete(self, request, pk):
''' Delete multiple constituents ''' ''' Delete multiple constituents '''
schema: models.RSForm = self.get_object() schema = self._get_schema()
serializer = serializers.CstListSerlializer(data=request.data, context={'schema': schema}) serializer = serializers.CstListSerlializer(data=request.data, context={'schema': schema})
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
schema.delete_cst(serializer.validated_data['constituents']) schema.delete_cst(serializer.validated_data['constituents'])
@ -85,7 +88,7 @@ class RSFormViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['patch'], url_path='cst-moveto') @action(detail=True, methods=['patch'], url_path='cst-moveto')
def cst_moveto(self, request, pk): def cst_moveto(self, request, pk):
''' Delete multiple constituents ''' ''' Delete multiple constituents '''
schema: models.RSForm = self.get_object() schema: models.RSForm = self._get_schema()
serializer = serializers.CstMoveSerlializer(data=request.data, context={'schema': schema}) serializer = serializers.CstMoveSerlializer(data=request.data, context={'schema': schema})
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
schema.move_cst(serializer.validated_data['constituents'], serializer.validated_data['move_to']) schema.move_cst(serializer.validated_data['constituents'], serializer.validated_data['move_to'])
@ -93,9 +96,46 @@ class RSFormViewSet(viewsets.ModelViewSet):
outSerializer = serializers.RSFormDetailsSerlializer(schema) outSerializer = serializers.RSFormDetailsSerlializer(schema)
return Response(status=200, data=outSerializer.data) return Response(status=200, data=outSerializer.data)
@action(detail=True, methods=['patch'], url_path='reset-aliases')
def reset_aliases(self, request, pk):
''' Recreate all aliases based on order '''
schema = self._get_schema()
result = json.loads(pyconcept.reset_aliases(json.dumps(schema.to_trs())))
schema.load_trs(data=result, sync_metadata=False, skip_update=True)
outSerializer = serializers.RSFormDetailsSerlializer(schema)
return Response(status=200, data=outSerializer.data)
@action(detail=True, methods=['patch'], url_path='load-trs')
def load_trs(self, request, pk):
''' Load data from file and replace current schema '''
serializer = serializers.RSFormUploadSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
schema = self._get_schema()
load_metadata = serializer.validated_data['load_metadata']
data = utils.read_trs(request.FILES['file'].file)
schema.load_trs(data, load_metadata, skip_update=False)
outSerializer = serializers.RSFormDetailsSerlializer(schema)
return Response(status=200, data=outSerializer.data)
@action(detail=True, methods=['post'], url_path='clone')
def clone(self, request, pk):
''' Clone RSForm constituents and create new schema using new metadata '''
serializer = serializers.RSFormSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
new_schema = models.RSForm.objects.create(
title=serializer.validated_data['title'],
owner=self.request.user,
alias=serializer.validated_data.get('alias', ''),
comment=serializer.validated_data.get('comment', ''),
is_common=serializer.validated_data.get('is_common', False),
)
new_schema.load_trs(data=self._get_schema().to_trs(), sync_metadata=False, skip_update=True)
outSerializer = serializers.RSFormDetailsSerlializer(new_schema)
return Response(status=201, data=outSerializer.data)
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
def claim(self, request, pk=None): def claim(self, request, pk=None):
schema: models.RSForm = self.get_object() schema = self._get_schema()
if schema.owner == self.request.user: if schema.owner == self.request.user:
return Response(status=304) return Response(status=304)
else: else:
@ -105,21 +145,21 @@ class RSFormViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['get']) @action(detail=True, methods=['get'])
def contents(self, request, pk): def contents(self, request, pk):
''' View schema contents (including constituents) ''' ''' View schema db contents (including constituents) '''
schema = self.get_object().to_json() schema = serializers.RSFormContentsSerializer(self._get_schema()).data
return Response(schema) return Response(schema)
@action(detail=True, methods=['get']) @action(detail=True, methods=['get'])
def details(self, request, pk): def details(self, request, pk):
''' Detailed schema view including statuses ''' ''' Detailed schema view including statuses '''
schema: models.RSForm = self.get_object() schema = self._get_schema()
serializer = serializers.RSFormDetailsSerlializer(schema) serializer = serializers.RSFormDetailsSerlializer(schema)
return Response(serializer.data) return Response(serializer.data)
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
def check(self, request, pk): def check(self, request, pk):
''' Check RS expression against schema context ''' ''' Check RS expression against schema context '''
schema = self.get_object().to_json() schema = self._get_schema().to_trs()
serializer = serializers.ExpressionSerializer(data=request.data) serializer = serializers.ExpressionSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
expression = serializer.validated_data['expression'] expression = serializer.validated_data['expression']
@ -129,9 +169,9 @@ class RSFormViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['get'], url_path='export-trs') @action(detail=True, methods=['get'], url_path='export-trs')
def export_trs(self, request, pk): def export_trs(self, request, pk):
''' Download Exteor compatible file ''' ''' Download Exteor compatible file '''
schema = self.get_object().to_json() schema = self._get_schema().to_trs()
trs = utils.write_trs(schema) trs = utils.write_trs(schema)
filename = self.get_object().alias filename = self._get_schema().alias
if filename == '' or not filename.isascii(): if filename == '' or not filename.isascii():
# Note: non-ascii symbols in Content-Disposition # Note: non-ascii symbols in Content-Disposition
# are not supported by some browsers # are not supported by some browsers
@ -152,7 +192,7 @@ class TrsImportView(views.APIView):
owner = self.request.user owner = self.request.user
if owner.is_anonymous: if owner.is_anonymous:
owner = None owner = None
schema = models.RSForm.import_json(owner, data) schema = models.RSForm.create_from_trs(owner, data)
result = serializers.RSFormSerializer(schema) result = serializers.RSFormSerializer(schema)
return Response(status=201, data=result.data) return Response(status=201, data=result.data)
@ -167,11 +207,11 @@ def create_rsform(request):
serializer = serializers.RSFormSerializer(data=request.data) serializer = serializers.RSFormSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
schema = models.RSForm.objects.create( schema = models.RSForm.objects.create(
title=request.data['title'], title=serializer.validated_data['title'],
owner=owner, owner=owner,
alias=request.data.get('alias', ''), alias=serializer.validated_data.get('alias', ''),
comment=request.data.get('comment', ''), comment=serializer.validated_data.get('comment', ''),
is_common=request.data.get('is_common', False), is_common=serializer.validated_data.get('is_common', False),
) )
else: else:
data = utils.read_trs(request.FILES['file'].file) data = utils.read_trs(request.FILES['file'].file)
@ -186,7 +226,7 @@ def create_rsform(request):
is_common = True is_common = True
if ('is_common' in request.data): if ('is_common' in request.data):
is_common = request.data['is_common'] == 'true' is_common = request.data['is_common'] == 'true'
schema = models.RSForm.import_json(owner, data, is_common) schema = models.RSForm.create_from_trs(owner, data, is_common)
result = serializers.RSFormSerializer(schema) result = serializers.RSFormSerializer(schema)
return Response(status=201, data=result.data) return Response(status=201, data=result.data)

View File

@ -21,17 +21,13 @@ class LoginSerializer(serializers.Serializer):
def validate(self, attrs): def validate(self, attrs):
username = attrs.get('username') username = attrs.get('username')
password = attrs.get('password') password = attrs.get('password')
if username and password: user = authenticate(
user = authenticate( request=self.context.get('request'),
request=self.context.get('request'), username=username,
username=username, password=password
password=password )
) if not user:
if not user: msg = 'Неправильное сочетание имени пользователя и пароля.'
msg = 'Неправильное сочетание имени пользователя и пароля.'
raise serializers.ValidationError(msg, code='authorization')
else:
msg = 'Заполните оба поля: Имя пользователя и Пароль.'
raise serializers.ValidationError(msg, code='authorization') raise serializers.ValidationError(msg, code='authorization')
attrs['user'] = user attrs['user'] = user
return attrs return attrs

View File

@ -29,3 +29,9 @@ class TestLoginSerializer(APITestCase):
request = self.factory.post('/users/api/login', data) request = self.factory.post('/users/api/login', data)
serializer = LoginSerializer(data=data, context={'request': request}) serializer = LoginSerializer(data=data, context={'request': request})
self.assertFalse(serializer.is_valid(raise_exception=False)) self.assertFalse(serializer.is_valid(raise_exception=False))
def test_validate_empty_username(self):
data = {'username': '', 'auth': 'invalid'}
request = self.factory.post('/users/api/login', data)
serializer = LoginSerializer(data=data, context={'request': request})
self.assertFalse(serializer.is_valid(raise_exception=False))

View File

@ -1,5 +1,5 @@
interface ButtonProps interface ButtonProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'className' | 'children'> { extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'className' | 'children' | 'title'> {
text?: string text?: string
icon?: React.ReactNode icon?: React.ReactNode
tooltip?: string tooltip?: string

View File

@ -5,7 +5,7 @@ import Button from './Button';
import Label from './Label'; import Label from './Label';
interface FileInputProps { interface FileInputProps {
id: string id?: string
required?: boolean required?: boolean
label: string label: string
acceptType?: string acceptType?: string
@ -33,7 +33,7 @@ function FileInput({ id, required, label, acceptType, widthClass = 'w-full', onC
}; };
return ( return (
<div className={'flex gap-2 py-2 mt-3 items-center ' + widthClass}> <div className={'flex flex-col gap-2 py-2 [&:not(:first-child)]:mt-3 items-start ' + widthClass}>
<input id={id} type='file' <input id={id} type='file'
ref={inputRef} ref={inputRef}
required={required} required={required}

View File

@ -0,0 +1,20 @@
interface MiniButtonProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'className' | 'title' > {
icon?: React.ReactNode
tooltip?: string
}
function MiniButton({ icon, tooltip, children, ...props }: MiniButtonProps) {
return (
<button type='button'
title={tooltip}
className='px-1 py-1 font-bold rounded-full cursor-pointer whitespace-nowrap disabled:cursor-not-allowed clr-btn-clear'
{...props}
>
{icon && <span>{icon}</span>}
{children}
</button>
);
}
export default MiniButton;

View File

@ -39,7 +39,7 @@ function Modal({ title, show, hideWindow, onSubmit, onCancel, canSubmit, childre
<div className='fixed top-0 left-0 z-50 w-full h-full opacity-50 clr-modal'> <div className='fixed top-0 left-0 z-50 w-full h-full opacity-50 clr-modal'>
</div> </div>
<div ref={ref} className='fixed bottom-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 px-6 py-4 flex flex-col w-fit z-[60] clr-card border shadow-md'> <div ref={ref} className='fixed bottom-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 px-6 py-4 flex flex-col w-fit z-[60] clr-card border shadow-md'>
{ title && <h1 className='mb-4 text-xl font-bold'>{title}</h1> } { title && <h1 className='mb-4 text-xl font-bold text-center'>{title}</h1> }
<div className='py-2'> <div className='py-2'>
{children} {children}
</div> </div>

View File

@ -5,12 +5,12 @@ import { type ErrorInfo } from '../components/BackendError'
import { useRSFormDetails } from '../hooks/useRSFormDetails' import { useRSFormDetails } from '../hooks/useRSFormDetails'
import { import {
type DataCallback, deleteRSForm, getTRSFile, type DataCallback, deleteRSForm, getTRSFile,
patchConstituenta, patchDeleteConstituenta, patchMoveConstituenta, patchRSForm, patchConstituenta, patchDeleteConstituenta, patchMoveConstituenta, patchResetAliases,
postClaimRSForm, postNewConstituenta patchRSForm,
} from '../utils/backendAPI' patchUploadTRS, postClaimRSForm, postNewConstituenta} from '../utils/backendAPI'
import { import {
IConstituenta, IConstituentaList, IConstituentaMeta, ICstCreateData, IConstituenta, IConstituentaList, IConstituentaMeta, ICstCreateData,
ICstMovetoData, ICstUpdateData, IRSForm, IRSFormMeta, IRSFormUpdateData ICstMovetoData, ICstUpdateData, IRSForm, IRSFormMeta, IRSFormUpdateData, IRSFormUploadData
} from '../utils/models' } from '../utils/models'
import { useAuth } from './AuthContext' import { useAuth } from './AuthContext'
@ -36,9 +36,11 @@ interface IRSFormContext {
toggleTracking: () => void toggleTracking: () => void
update: (data: IRSFormUpdateData, callback?: DataCallback<IRSFormMeta>) => void update: (data: IRSFormUpdateData, callback?: DataCallback<IRSFormMeta>) => void
destroy: (callback?: DataCallback) => void destroy: (callback?: () => void) => void
claim: (callback?: DataCallback<IRSFormMeta>) => void claim: (callback?: DataCallback<IRSFormMeta>) => void
download: (callback: DataCallback<Blob>) => void download: (callback: DataCallback<Blob>) => void
upload: (data: IRSFormUploadData, callback: () => void) => void
resetAliases: (callback: () => void) => void
cstCreate: (data: ICstCreateData, callback?: DataCallback<IConstituentaMeta>) => void cstCreate: (data: ICstCreateData, callback?: DataCallback<IConstituentaMeta>) => void
cstUpdate: (data: ICstUpdateData, callback?: DataCallback<IConstituentaMeta>) => void cstUpdate: (data: ICstUpdateData, callback?: DataCallback<IConstituentaMeta>) => void
@ -113,17 +115,35 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
} }
}); });
}, [schemaID, setError, setSchema, schema]) }, [schemaID, setError, setSchema, schema])
const upload = useCallback(
(data: IRSFormUploadData, callback?: () => void) => {
if (!schema) {
return;
}
setError(undefined)
patchUploadTRS(schemaID, {
data: data,
showError: true,
setLoading: setProcessing,
onError: error => { setError(error) },
onSuccess: newData => {
setSchema(newData);
if (callback) callback();
}
});
}, [schemaID, setError, setSchema, schema])
const destroy = useCallback( const destroy = useCallback(
(callback?: DataCallback) => { (callback?: () => void) => {
setError(undefined) setError(undefined)
deleteRSForm(schemaID, { deleteRSForm(schemaID, {
showError: true, showError: true,
setLoading: setProcessing, setLoading: setProcessing,
onError: error => { setError(error) }, onError: error => { setError(error) },
onSuccess: newData => { onSuccess: () => {
setSchema(undefined); setSchema(undefined);
if (callback) callback(newData); if (callback) callback();
} }
}); });
}, [schemaID, setError, setSchema]) }, [schemaID, setError, setSchema])
@ -145,6 +165,23 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
}); });
}, [schemaID, setError, schema, user, setSchema]) }, [schemaID, setError, schema, user, setSchema])
const resetAliases = useCallback(
(callback?: () => void) => {
if (!schema || !user) {
return;
}
setError(undefined)
patchResetAliases(schemaID, {
showError: true,
setLoading: setProcessing,
onError: error => { setError(error) },
onSuccess: newData => {
setSchema(Object.assign(schema, newData));
if (callback) callback();
}
});
}, [schemaID, setError, schema, user, setSchema])
const download = useCallback( const download = useCallback(
(callback: DataCallback<Blob>) => { (callback: DataCallback<Blob>) => {
setError(undefined) setError(undefined)
@ -218,29 +255,15 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
return ( return (
<RSFormContext.Provider value={{ <RSFormContext.Provider value={{
schema, schema,
error, error, loading, processing,
loading, activeID, activeCst, setActiveID,
processing, isForceAdmin, isReadonly, isOwned, isEditable,
activeID, isClaimable, isTracking,
activeCst,
setActiveID,
isForceAdmin,
isReadonly,
toggleForceAdmin: () => { setIsForceAdmin(prev => !prev) }, toggleForceAdmin: () => { setIsForceAdmin(prev => !prev) },
toggleReadonly: () => { setIsReadonly(prev => !prev) }, toggleReadonly: () => { setIsReadonly(prev => !prev) },
isOwned,
isEditable,
isClaimable,
isTracking,
toggleTracking, toggleTracking,
update, update, download, upload, destroy, claim, resetAliases,
download, cstUpdate, cstCreate, cstDelete, cstMoveTo
destroy,
claim,
cstUpdate,
cstCreate,
cstDelete,
cstMoveTo
}}> }}>
{ children } { children }
</RSFormContext.Provider> </RSFormContext.Provider>

View File

@ -69,7 +69,7 @@
/* Transparent button */ /* Transparent button */
.clr-btn-clear { .clr-btn-clear {
@apply hover:bg-gray-300 dark:hover:bg-gray-400 @apply hover:bg-gray-300 dark:hover:bg-gray-400 dark:disabled:text-zinc-400 disabled:text-gray-400 text-gray-500 dark:text-zinc-200
} }
.clr-checkbox { .clr-checkbox {

View File

@ -53,7 +53,7 @@ function RSFormCreatePage() {
file: file, file: file,
fileName: file?.name fileName: file?.name
}; };
void createSchema(data, onSuccess); createSchema(data, onSuccess);
}; };
return ( return (

View File

@ -2,6 +2,7 @@ import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import MiniButton from '../../components/Common/MiniButton';
import SubmitButton from '../../components/Common/SubmitButton'; import SubmitButton from '../../components/Common/SubmitButton';
import TextArea from '../../components/Common/TextArea'; import TextArea from '../../components/Common/TextArea';
import { DumpBinIcon, SaveIcon, SmallPlusIcon } from '../../components/Icons'; import { DumpBinIcon, SaveIcon, SmallPlusIcon } from '../../components/Icons';
@ -11,7 +12,7 @@ import { createAliasFor, getCstTypeLabel } from '../../utils/staticUI';
import ConstituentsSideList from './ConstituentsSideList'; import ConstituentsSideList from './ConstituentsSideList';
import CreateCstModal from './CreateCstModal'; import CreateCstModal from './CreateCstModal';
import ExpressionEditor from './ExpressionEditor'; import ExpressionEditor from './ExpressionEditor';
import { RSFormTabsList } from './RSFormTabs'; import { RSTabsList } from './RSTabs';
function ConstituentEditor() { function ConstituentEditor() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -113,7 +114,7 @@ function ConstituentEditor() {
insert_after: activeID insert_after: activeID
} }
cstCreate(data, newCst => { cstCreate(data, newCst => {
navigate(`/rsforms/${schema.id}?tab=${RSFormTabsList.CST_EDIT}&active=${newCst.id}`); navigate(`/rsforms/${schema.id}?tab=${RSTabsList.CST_EDIT}&active=${newCst.id}`);
toast.success(`Конституента добавлена: ${newCst.alias}`); toast.success(`Конституента добавлена: ${newCst.alias}`);
}); });
} }
@ -167,22 +168,18 @@ function ConstituentEditor() {
</span> </span>
</div> </div>
<div className='flex justify-end'> <div className='flex justify-end'>
<button type='button' <MiniButton
title='Создать конституенты после данной' tooltip='Создать конституенты после данной'
className='px-1 py-1 font-bold rounded-full whitespace-nowrap disabled:cursor-not-allowed clr-btn-clear'
disabled={!isEnabled} disabled={!isEnabled}
onClick={() => { handleAddNew(); }} onClick={() => { handleAddNew(); }}
> icon={<SmallPlusIcon size={5} color={isEnabled ? 'text-green' : ''} />}
<SmallPlusIcon size={5} color={isEnabled ? 'text-green' : ''} /> />
</button> <MiniButton
<button type='button' tooltip='Удалить редактируемую конституенту'
title='Удалить редактируемую конституенту'
className='px-1 py-1 font-bold rounded-full whitespace-nowrap disabled:cursor-not-allowed clr-btn-clear'
disabled={!isEnabled} disabled={!isEnabled}
onClick={handleDelete} onClick={handleDelete}
> icon={<DumpBinIcon size={5} color={isEnabled ? 'text-red' : ''} />}
<DumpBinIcon size={5} color={isEnabled ? 'text-red' : ''} /> />
</button>
</div> </div>
</div> </div>
<TextArea id='term' label='Термин' <TextArea id='term' label='Термин'

View File

@ -18,13 +18,14 @@ interface ConstituentsTableProps {
function ConstituentsTable({ onOpenEdit }: ConstituentsTableProps) { function ConstituentsTable({ onOpenEdit }: ConstituentsTableProps) {
const { const {
schema, isEditable, schema, isEditable,
cstCreate, cstDelete, cstMoveTo cstCreate, cstDelete, cstMoveTo, resetAliases
} = useRSForm(); } = useRSForm();
const { noNavigation } = useConceptTheme(); const { noNavigation } = useConceptTheme();
const [selected, setSelected] = useState<number[]>([]); const [selected, setSelected] = useState<number[]>([]);
const nothingSelected = useMemo(() => selected.length === 0, [selected]); const nothingSelected = useMemo(() => selected.length === 0, [selected]);
const [showCstModal, setShowCstModal] = useState(false); const [showCstModal, setShowCstModal] = useState(false);
const [toggledClearRows, setToggledClearRows] = useState(false);
const handleRowClicked = useCallback( const handleRowClicked = useCallback(
(cst: IConstituenta, event: React.MouseEvent<Element, MouseEvent>) => { (cst: IConstituenta, event: React.MouseEvent<Element, MouseEvent>) => {
@ -39,8 +40,8 @@ function ConstituentsTable({ onOpenEdit }: ConstituentsTableProps) {
selectedCount: number selectedCount: number
selectedRows: IConstituenta[] selectedRows: IConstituenta[]
}) => { }) => {
setSelected(selectedRows.map((cst) => cst.id)); setSelected(selectedRows.map(cst => cst.id));
}, [setSelected]); }, [setSelected]);
// Delete selected constituents // Delete selected constituents
const handleDelete = useCallback(() => { const handleDelete = useCallback(() => {
@ -50,8 +51,11 @@ function ConstituentsTable({ onOpenEdit }: ConstituentsTableProps) {
const data = { const data = {
items: selected.map(id => { return { id }; }) items: selected.map(id => { return { id }; })
} }
const deletedNames = selected.map(id => schema.items?.find((cst) => cst.id === id)?.alias).join(', '); const deletedNames = selected.map(id => schema.items?.find(cst => cst.id === id)?.alias).join(', ');
cstDelete(data, () => toast.success(`Конституенты удалены: ${deletedNames}`)); cstDelete(data, () => {
toast.success(`Конституенты удалены: ${deletedNames}`);
setToggledClearRows(prev => !prev);
});
}, [selected, schema?.items, cstDelete]); }, [selected, schema?.items, cstDelete]);
// Move selected cst up // Move selected cst up
@ -70,7 +74,7 @@ function ConstituentsTable({ onOpenEdit }: ConstituentsTableProps) {
}, -1); }, -1);
const target = Math.max(0, currentIndex - 1) + 1 const target = Math.max(0, currentIndex - 1) + 1
const data = { const data = {
items: selected.map(id => { return { id }; }), items: selected.map(id => { return { id: id }; }),
move_to: target move_to: target
} }
cstMoveTo(data); cstMoveTo(data);
@ -96,16 +100,16 @@ function ConstituentsTable({ onOpenEdit }: ConstituentsTableProps) {
}, -1); }, -1);
const target = Math.min(schema.items.length - 1, currentIndex - count + 2) + 1 const target = Math.min(schema.items.length - 1, currentIndex - count + 2) + 1
const data: ICstMovetoData = { const data: ICstMovetoData = {
items: selected.map(id => { return { id }; }), items: selected.map(id => { return { id: id }; }),
move_to: target move_to: target
} }
cstMoveTo(data); cstMoveTo(data);
}, [selected, schema?.items, cstMoveTo]); }, [selected, schema?.items, cstMoveTo]);
// Generate new names for all constituents // Generate new names for all constituents
const handleReindex = useCallback(() => { const handleReindex = useCallback(() => {
toast.info('Переиндексация'); resetAliases(() => toast.success('Переиндексация конституент успешна'));
}, []); }, [resetAliases]);
// Add new constituent // Add new constituent
const handleAddNew = useCallback((type?: CstType) => { const handleAddNew = useCallback((type?: CstType) => {
@ -130,7 +134,15 @@ function ConstituentsTable({ onOpenEdit }: ConstituentsTableProps) {
// Implement hotkeys for working with constituents table // Implement hotkeys for working with constituents table
function handleTableKey(event: React.KeyboardEvent<HTMLDivElement>) { function handleTableKey(event: React.KeyboardEvent<HTMLDivElement>) {
if (!event.altKey || !isEditable || event.shiftKey) { if (!isEditable) {
return;
}
if (event.key === 'Delete' && selected.length > 0) {
event.preventDefault();
handleDelete();
return;
}
if (!event.altKey || event.shiftKey) {
return; return;
} }
if (processAltKey(event.key)) { if (processAltKey(event.key)) {
@ -335,7 +347,11 @@ function ConstituentsTable({ onOpenEdit }: ConstituentsTableProps) {
})} })}
</div>} </div>}
</div> </div>
<div className='w-full h-full' onKeyDown={handleTableKey} tabIndex={0}> <div className='w-full h-full'
onKeyDown={handleTableKey}
tabIndex={0}
title='Горячие клавиши:&#013;Двойной клик / Alt + клик - редактирование конституенты&#013;Alt + вверх/вниз - движение конституент&#013;Delete - удаление выбранных&#013;Alt + 1-6, Q,W - добавление конституент'
>
<DataTableThemed <DataTableThemed
data={schema?.items ?? []} data={schema?.items ?? []}
columns={columns} columns={columns}
@ -356,6 +372,7 @@ function ConstituentsTable({ onOpenEdit }: ConstituentsTableProps) {
onSelectedRowsChange={handleSelectionChange} onSelectedRowsChange={handleSelectionChange}
onRowDoubleClicked={onOpenEdit} onRowDoubleClicked={onOpenEdit}
onRowClicked={handleRowClicked} onRowClicked={handleRowClicked}
clearSelectedRows={toggledClearRows}
dense dense
/> />
</div> </div>

View File

@ -3,8 +3,8 @@ import { useIntl } from 'react-intl';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import Button from '../../components/Common/Button';
import Checkbox from '../../components/Common/Checkbox'; import Checkbox from '../../components/Common/Checkbox';
import MiniButton from '../../components/Common/MiniButton';
import SubmitButton from '../../components/Common/SubmitButton'; import SubmitButton from '../../components/Common/SubmitButton';
import TextArea from '../../components/Common/TextArea'; import TextArea from '../../components/Common/TextArea';
import TextInput from '../../components/Common/TextInput'; import TextInput from '../../components/Common/TextInput';
@ -76,6 +76,32 @@ function RSFormCard() {
return ( return (
<form onSubmit={handleSubmit} className='flex-grow max-w-xl px-4 py-2 border min-w-fit'> <form onSubmit={handleSubmit} className='flex-grow max-w-xl px-4 py-2 border min-w-fit'>
<div className='relative w-full'>
<div className='absolute top-0 right-0'>
<MiniButton
tooltip='Поделиться схемой'
icon={<ShareIcon size={5} color='text-primary'/>}
onClick={shareCurrentURLProc}
/>
<MiniButton
tooltip='Скачать TRS файл'
icon={<DownloadIcon size={5} color='text-primary'/>}
onClick={handleDownload}
/>
<MiniButton
tooltip={isClaimable ? 'Стать владельцем' : 'Вы уже являетесь владельцем' }
icon={<CrownIcon size={5} color={isOwned ? '' : 'text-green'}/>}
disabled={!isClaimable || !user}
onClick={() => { claimOwnershipProc(claim); }}
/>
<MiniButton
tooltip='Удалить схему'
disabled={!isEditable}
onClick={handleDelete}
icon={<DumpBinIcon size={5} color={isEditable ? 'text-red' : ''} />}
/>
</div>
</div>
<TextInput id='title' label='Полное название' type='text' <TextInput id='title' label='Полное название' type='text'
required required
value={title} value={title}
@ -107,33 +133,6 @@ function RSFormCard() {
disabled={!isModified || !isEditable} disabled={!isModified || !isEditable}
icon={<SaveIcon size={6} />} icon={<SaveIcon size={6} />}
/> />
<div className='flex justify-end gap-1'>
<Button
tooltip='Поделиться схемой'
icon={<ShareIcon color='text-primary'/>}
onClick={shareCurrentURLProc}
/>
<Button
tooltip='Скачать TRS файл'
icon={<DownloadIcon color='text-primary'/>}
loading={processing}
onClick={handleDownload}
/>
<Button
tooltip={isClaimable ? 'Стать владельцем' : 'Вы уже являетесь владельцем' }
icon={<CrownIcon color={isOwned ? '' : 'text-green'}/>}
loading={processing}
disabled={!isClaimable || !user}
onClick={() => { claimOwnershipProc(claim); }}
/>
<Button
tooltip={ isEditable ? 'Удалить схему' : 'Вы не можете редактировать данную схему'}
icon={<DumpBinIcon color={isEditable ? 'text-red' : ''} />}
loading={processing}
disabled={!isEditable}
onClick={handleDelete}
/>
</div>
</div> </div>
<div className='flex justify-start mt-2'> <div className='flex justify-start mt-2'>

View File

@ -11,23 +11,23 @@ import ConstituentEditor from './ConstituentEditor';
import ConstituentsTable from './ConstituentsTable'; import ConstituentsTable from './ConstituentsTable';
import RSFormCard from './RSFormCard'; import RSFormCard from './RSFormCard';
import RSFormStats from './RSFormStats'; import RSFormStats from './RSFormStats';
import TablistTools from './TablistTools'; import RSTabsMenu from './RSTabsMenu';
export enum RSFormTabsList { export enum RSTabsList {
CARD = 0, CARD = 0,
CST_LIST = 1, CST_LIST = 1,
CST_EDIT = 2 CST_EDIT = 2
} }
function RSFormTabs() { function RSTabs() {
const { setActiveID, activeID, error, schema, loading } = useRSForm(); const { setActiveID, activeID, error, schema, loading } = useRSForm();
const [tabIndex, setTabIndex] = useLocalStorage('rsform_edit_tab', RSFormTabsList.CARD); const [tabIndex, setTabIndex] = useLocalStorage('rsform_edit_tab', RSTabsList.CARD);
const [init, setInit] = useState(false); const [init, setInit] = useState(false);
const onEditCst = (cst: IConstituenta) => { const onEditCst = (cst: IConstituenta) => {
console.log(`Set active cst: ${cst.alias}`); console.log(`Set active cst: ${cst.alias}`);
setActiveID(cst.id); setActiveID(cst.id);
setTabIndex(RSFormTabsList.CST_EDIT) setTabIndex(RSTabsList.CST_EDIT)
}; };
const onSelectTab = (index: number) => { const onSelectTab = (index: number) => {
@ -47,7 +47,7 @@ function RSFormTabs() {
useEffect(() => { useEffect(() => {
const url = new URL(window.location.href); const url = new URL(window.location.href);
const tabQuery = url.searchParams.get('tab'); const tabQuery = url.searchParams.get('tab');
setTabIndex(Number(tabQuery) || RSFormTabsList.CARD); setTabIndex(Number(tabQuery) || RSTabsList.CARD);
}, [setTabIndex]); }, [setTabIndex]);
useEffect(() => { useEffect(() => {
@ -55,7 +55,7 @@ function RSFormTabs() {
const url = new URL(window.location.href); const url = new URL(window.location.href);
const currentActive = url.searchParams.get('active'); const currentActive = url.searchParams.get('active');
const currentTab = url.searchParams.get('tab'); const currentTab = url.searchParams.get('tab');
const saveHistory = tabIndex === RSFormTabsList.CST_EDIT && currentActive !== String(activeID); const saveHistory = tabIndex === RSTabsList.CST_EDIT && currentActive !== String(activeID);
if (currentTab !== String(tabIndex)) { if (currentTab !== String(tabIndex)) {
url.searchParams.set('tab', String(tabIndex)); url.searchParams.set('tab', String(tabIndex));
} }
@ -86,7 +86,7 @@ function RSFormTabs() {
selectedTabClassName='font-bold' selectedTabClassName='font-bold'
> >
<TabList className='flex items-start w-fit clr-bg-pop'> <TabList className='flex items-start w-fit clr-bg-pop'>
<TablistTools /> <RSTabsMenu />
<ConceptTab>Паспорт схемы</ConceptTab> <ConceptTab>Паспорт схемы</ConceptTab>
<ConceptTab className='border-x-2 clr-border min-w-[10rem] flex justify-between gap-2'> <ConceptTab className='border-x-2 clr-border min-w-[10rem] flex justify-between gap-2'>
<span>Конституенты</span> <span>Конституенты</span>
@ -112,4 +112,4 @@ function RSFormTabs() {
</div>); </div>);
} }
export default RSFormTabs; export default RSTabs;

View File

@ -1,4 +1,4 @@
import { useCallback } from 'react'; import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@ -11,8 +11,9 @@ import { useAuth } from '../../context/AuthContext';
import { useRSForm } from '../../context/RSFormContext'; import { useRSForm } from '../../context/RSFormContext';
import useDropdown from '../../hooks/useDropdown'; import useDropdown from '../../hooks/useDropdown';
import { claimOwnershipProc, deleteRSFormProc, downloadRSFormProc, shareCurrentURLProc } from '../../utils/procedures'; import { claimOwnershipProc, deleteRSFormProc, downloadRSFormProc, shareCurrentURLProc } from '../../utils/procedures';
import UploadRSFormModal from './UploadRSFormModal';
function TablistTools() { function RSTabsMenu() {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
const { const {
@ -23,6 +24,7 @@ function TablistTools() {
} = useRSForm(); } = useRSForm();
const schemaMenu = useDropdown(); const schemaMenu = useDropdown();
const editMenu = useDropdown(); const editMenu = useDropdown();
const [showUploadModal, setShowUploadModal] = useState(false);
const handleClaimOwner = useCallback(() => { const handleClaimOwner = useCallback(() => {
editMenu.hide(); editMenu.hide();
@ -41,9 +43,8 @@ function TablistTools() {
}, [schemaMenu, download, schema?.alias]); }, [schemaMenu, download, schema?.alias]);
const handleUpload = useCallback(() => { const handleUpload = useCallback(() => {
// TODO: implement
schemaMenu.hide(); schemaMenu.hide();
toast.info('Замена содержимого на файл Экстеора'); setShowUploadModal(true);
}, [schemaMenu]); }, [schemaMenu]);
const handleClone = useCallback(() => { const handleClone = useCallback(() => {
@ -58,6 +59,11 @@ function TablistTools() {
}, [schemaMenu]); }, [schemaMenu]);
return ( return (
<>
<UploadRSFormModal
show={showUploadModal}
hideWindow={() => { setShowUploadModal(false); }}
/>
<div className='flex items-center w-fit'> <div className='flex items-center w-fit'>
<div ref={schemaMenu.ref}> <div ref={schemaMenu.ref}>
<Button <Button
@ -142,7 +148,8 @@ function TablistTools() {
/> />
</div> </div>
</div> </div>
</>
); );
} }
export default TablistTools export default RSTabsMenu

View File

@ -0,0 +1,63 @@
import { useState } from 'react';
import { toast } from 'react-toastify';
import Checkbox from '../../components/Common/Checkbox';
import FileInput from '../../components/Common/FileInput';
import Modal from '../../components/Common/Modal';
import { useRSForm } from '../../context/RSFormContext';
import { IRSFormUploadData } from '../../utils/models';
interface UploadRSFormModalProps {
show: boolean
hideWindow: () => void
}
function UploadRSFormModal({ show, hideWindow }: UploadRSFormModalProps) {
const { upload } = useRSForm();
const [loadMetadata, setLoadMetadata] = useState(false);
const [file, setFile] = useState<File | undefined>()
const handleSubmit = () => {
hideWindow();
if (!file) {
return;
}
const data: IRSFormUploadData = {
load_metadata: loadMetadata,
file: file,
fileName: file.name
};
upload(data, () => toast.success('Схема загружена из файла'));
};
const handleFile = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files.length > 0) {
setFile(event.target.files[0]);
} else {
setFile(undefined)
}
}
return (
<Modal
title='Загрузка схемы из Экстеор'
show={show}
hideWindow={hideWindow}
canSubmit={!!file}
onSubmit={handleSubmit}
>
<div className='max-w-[20rem]'>
<FileInput label='Загрузить файл'
acceptType='.trs'
onChange={handleFile}
/>
<Checkbox label='Загружать метаданные'
value={loadMetadata}
onChange={event => { setLoadMetadata(event.target.checked); }}
/>
</div>
</Modal>
)
}
export default UploadRSFormModal;

View File

@ -1,13 +1,13 @@
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { RSFormState } from '../../context/RSFormContext'; import { RSFormState } from '../../context/RSFormContext';
import RSFormTabs from './RSFormTabs'; import RSTabs from './RSTabs';
function RSFormPage() { function RSFormPage() {
const { id } = useParams(); const { id } = useParams();
return ( return (
<RSFormState schemaID={id ?? ''}> <RSFormState schemaID={id ?? ''}>
<RSFormTabs /> <RSTabs />
</RSFormState> </RSFormState>
); );
} }

View File

@ -8,12 +8,10 @@ import {
ExpressionParse, ExpressionParse,
IConstituentaList, IConstituentaList,
IConstituentaMeta, IConstituentaMeta,
ICstCreateData, ICstCreateData, ICstCreatedResponse, ICstMovetoData, ICstUpdateData,
ICstCreatedResponse,
ICstMovetoData,
ICstUpdateData,
ICurrentUser, IRSFormCreateData, IRSFormData, ICurrentUser, IRSFormCreateData, IRSFormData,
IRSFormMeta, IRSFormUpdateData, IUserInfo, IUserLoginData, IUserProfile, IUserSignupData, RSExpression IRSFormMeta, IRSFormUpdateData, IRSFormUploadData, IUserInfo,
IUserLoginData, IUserProfile, IUserSignupData, RSExpression
} from './models' } from './models'
// ================ Data transfer types ================ // ================ Data transfer types ================
@ -203,9 +201,30 @@ export function postCheckExpression(schema: string, request: FrontExchange<RSExp
}); });
} }
export function patchResetAliases(target: string, request: FrontPull<IRSFormData>) {
AxiosPatch({
title: `Reset alias for RSForm id=${target}`,
endpoint: `${config.url.BASE}rsforms/${target}/reset-aliases/`,
request: request
});
}
export function patchUploadTRS(target: string, request: FrontExchange<IRSFormUploadData, IRSFormData>) {
AxiosPatch({
title: `Replacing data with trs file for RSForm id=${target}`,
endpoint: `${config.url.BASE}rsforms/${target}/load-trs/`,
request: request,
options: {
headers: {
'Content-Type': 'multipart/form-data'
}
}
});
}
// ============ Helper functions ============= // ============ Helper functions =============
function AxiosGet<ResponseData>({ endpoint, request, title, options }: IAxiosRequest<undefined, ResponseData>) { function AxiosGet<ResponseData>({ endpoint, request, title, options }: IAxiosRequest<undefined, ResponseData>) {
console.log(`[[${title}]] requested`); console.log(`REQUEST: [[${title}]]`);
if (request.setLoading) request?.setLoading(true); if (request.setLoading) request?.setLoading(true);
axios.get<ResponseData>(endpoint, options) axios.get<ResponseData>(endpoint, options)
.then((response) => { .then((response) => {
@ -222,7 +241,7 @@ function AxiosGet<ResponseData>({ endpoint, request, title, options }: IAxiosReq
function AxiosPost<RequestData, ResponseData>( function AxiosPost<RequestData, ResponseData>(
{ endpoint, request, title, options }: IAxiosRequest<RequestData, ResponseData> { endpoint, request, title, options }: IAxiosRequest<RequestData, ResponseData>
) { ) {
console.log(`[[${title}]] posted`); console.log(`POST: [[${title}]]`);
if (request.setLoading) request.setLoading(true); if (request.setLoading) request.setLoading(true);
axios.post<ResponseData>(endpoint, request.data, options) axios.post<ResponseData>(endpoint, request.data, options)
.then((response) => { .then((response) => {
@ -239,7 +258,7 @@ function AxiosPost<RequestData, ResponseData>(
function AxiosDelete<RequestData, ResponseData>( function AxiosDelete<RequestData, ResponseData>(
{ endpoint, request, title, options }: IAxiosRequest<RequestData, ResponseData> { endpoint, request, title, options }: IAxiosRequest<RequestData, ResponseData>
) { ) {
console.log(`[[${title}]] is being deleted`); console.log(`DELETE: [[${title}]]`);
if (request.setLoading) request.setLoading(true); if (request.setLoading) request.setLoading(true);
axios.delete<ResponseData>(endpoint, options) axios.delete<ResponseData>(endpoint, options)
.then((response) => { .then((response) => {
@ -256,7 +275,7 @@ function AxiosDelete<RequestData, ResponseData>(
function AxiosPatch<RequestData, ResponseData>( function AxiosPatch<RequestData, ResponseData>(
{ endpoint, request, title, options }: IAxiosRequest<RequestData, ResponseData> { endpoint, request, title, options }: IAxiosRequest<RequestData, ResponseData>
) { ) {
console.log(`[[${title}]] is being patrially updated`); console.log(`PATCH: [[${title}]]`);
if (request.setLoading) request.setLoading(true); if (request.setLoading) request.setLoading(true);
axios.patch<ResponseData>(endpoint, request.data, options) axios.patch<ResponseData>(endpoint, request.data, options)
.then((response) => { .then((response) => {

View File

@ -179,6 +179,12 @@ extends IRSFormUpdateData {
fileName?: string fileName?: string
} }
export interface IRSFormUploadData {
load_metadata: boolean
file: File
fileName: string
}
// ================ Misc types ================ // ================ Misc types ================
// Constituenta edit mode // Constituenta edit mode
export enum EditMode { export enum EditMode {

View File

@ -21,7 +21,7 @@ export function claimOwnershipProc(
} }
export function deleteRSFormProc( export function deleteRSFormProc(
destroy: (callback: DataCallback) => void, destroy: (callback?: () => void) => void,
navigate: (path: string) => void navigate: (path: string) => void
) { ) {
if (!window.confirm('Вы уверены, что хотите удалить данную схему?')) { if (!window.confirm('Вы уверены, что хотите удалить данную схему?')) {