Implement subscriptions and improve UI

This commit is contained in:
IRBorisov 2023-08-26 17:26:49 +03:00
parent fba16a3d0b
commit 743ddf298e
31 changed files with 672 additions and 303 deletions

View File

@ -8,9 +8,14 @@ class ConstituentaAdmin(admin.ModelAdmin):
''' Admin model: Constituenta. '''
class Librarydmin(admin.ModelAdmin):
class LibraryAdmin(admin.ModelAdmin):
''' Admin model: LibraryItem. '''
class SubscriptionAdmin(admin.ModelAdmin):
''' Admin model: Subscriptions. '''
admin.site.register(models.Constituenta, ConstituentaAdmin)
admin.site.register(models.LibraryItem, Librarydmin)
admin.site.register(models.LibraryItem, LibraryAdmin)
admin.site.register(models.Subscription, SubscriptionAdmin)

View File

@ -1,4 +1,4 @@
# Generated by Django 4.2.4 on 2023-08-25 12:15
# Generated by Django 4.2.4 on 2023-08-26 10:09
import apps.rsform.models
from django.conf import settings
@ -35,18 +35,6 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'Схемы',
},
),
migrations.CreateModel(
name='Subscription',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='rsform.libraryitem', verbose_name='Элемент')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
],
options={
'verbose_name': 'Подписки',
'verbose_name_plural': 'Подписка',
},
),
migrations.CreateModel(
name='Constituenta',
fields=[
@ -68,4 +56,17 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'Конституенты',
},
),
migrations.CreateModel(
name='Subscription',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='rsform.libraryitem', verbose_name='Элемент')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
],
options={
'verbose_name': 'Подписки',
'verbose_name_plural': 'Подписка',
'unique_together': {('user', 'item')},
},
),
]

View File

@ -127,6 +127,17 @@ class LibraryItem(Model):
def get_absolute_url(self):
return f'/api/library/{self.pk}/'
def subscribers(self) -> list[User]:
''' Get all subscribers for this item . '''
return [s.user for s in Subscription.objects.filter(item=self.pk)]
@transaction.atomic
def save(self, *args, **kwargs):
subscribe = not self.pk and self.owner
super().save(*args, **kwargs)
if subscribe:
Subscription.subscribe(user=self.owner, item=self)
class Subscription(Model):
''' User subscription to library item. '''
@ -145,10 +156,28 @@ class Subscription(Model):
''' Model metadata. '''
verbose_name = 'Подписки'
verbose_name_plural = 'Подписка'
unique_together = [['user', 'item']]
def __str__(self) -> str:
return f'{self.user} -> {self.item}'
@staticmethod
def subscribe(user: User, item: LibraryItem) -> bool:
''' Add subscription. '''
if Subscription.objects.filter(user=user, item=item).exists():
return False
Subscription.objects.create(user=user, item=item)
return True
@staticmethod
def unsubscribe(user: User, item: LibraryItem) -> bool:
''' Remove subscription. '''
sub = Subscription.objects.filter(user=user, item=item)
if not sub.exists():
return False
sub.delete()
return True
class Constituenta(Model):
''' Constituenta is the base unit for every conceptual schema '''
@ -514,6 +543,7 @@ class PyConceptAdapter:
result['time_update'] = self.schema.item.time_update
result['time_create'] = self.schema.item.time_create
result['is_common'] = self.schema.item.is_common
result['is_canonical'] = self.schema.item.is_canonical
result['owner'] = (self.schema.item.owner.pk if self.schema.item.owner is not None else None)
for cst_data in result['items']:
cst = Constituenta.objects.get(pk=cst_data['id'])
@ -527,6 +557,7 @@ class PyConceptAdapter:
'raw': cst.definition_raw,
'resolved': cst.definition_resolved,
}
result['subscribers'] = [item.pk for item in self.schema.item.subscribers()]
return result
def _prepare_request(self) -> dict:

View File

@ -5,12 +5,9 @@ from django.db.utils import IntegrityError
from django.forms import ValidationError
from apps.rsform.models import (
RSForm,
Constituenta,
CstType,
RSForm, Constituenta, CstType,
User,
LibraryItem,
LibraryItemType
LibraryItem, LibraryItemType, Subscription
)
@ -28,7 +25,7 @@ class TestConstituenta(TestCase):
def test_url(self):
testStr = 'X1'
cst = Constituenta.objects.create(alias=testStr, schema=self.schema1, order=1, convention='Test')
self.assertEqual(cst.get_absolute_url(), f'/api/constituents/{cst.id}/')
self.assertEqual(cst.get_absolute_url(), f'/api/constituents/{cst.id}')
def test_order_not_null(self):
with self.assertRaises(IntegrityError):
@ -110,7 +107,39 @@ class TestLibraryItem(TestCase):
self.assertEqual(item.comment, 'Test comment')
self.assertEqual(item.is_common, True)
self.assertEqual(item.is_canonical, True)
self.assertTrue(Subscription.objects.filter(user=item.owner, item=item).exists())
def test_subscribe(self):
item = LibraryItem.objects.create(item_type=LibraryItemType.RSFORM, title='Test')
self.assertEqual(len(item.subscribers()), 0)
self.assertTrue(Subscription.subscribe(self.user1, item))
self.assertEqual(len(item.subscribers()), 1)
self.assertTrue(self.user1 in item.subscribers())
self.assertFalse(Subscription.subscribe(self.user1, item))
self.assertEqual(len(item.subscribers()), 1)
self.assertTrue(Subscription.subscribe(self.user2, item))
self.assertEqual(len(item.subscribers()), 2)
self.assertTrue(self.user1 in item.subscribers())
self.assertTrue(self.user2 in item.subscribers())
self.user1.delete()
self.assertEqual(len(item.subscribers()), 1)
def test_unsubscribe(self):
item = LibraryItem.objects.create(item_type=LibraryItemType.RSFORM, title='Test')
self.assertFalse(Subscription.unsubscribe(self.user1, item))
Subscription.subscribe(self.user1, item)
Subscription.subscribe(self.user2, item)
self.assertEqual(len(item.subscribers()), 2)
self.assertTrue(Subscription.unsubscribe(self.user1, item))
self.assertEqual(len(item.subscribers()), 1)
self.assertTrue(self.user2 in item.subscribers())
self.assertFalse(Subscription.unsubscribe(self.user1, item))
class TestRSForm(TestCase):
''' Testing RSForm wrapper. '''

View File

@ -9,7 +9,7 @@ from rest_framework.exceptions import ErrorDetail
from cctext import ReferenceType
from apps.users.models import User
from apps.rsform.models import Syntax, RSForm, Constituenta, CstType, LibraryItem, LibraryItemType
from apps.rsform.models import Syntax, RSForm, Constituenta, CstType, LibraryItem, LibraryItemType, Subscription
from apps.rsform.views import (
convert_to_ascii,
convert_to_math,
@ -43,28 +43,28 @@ class TestConstituentaAPI(APITestCase):
definition_raw='Test1', definition_resolved='Test2')
def test_retrieve(self):
response = self.client.get(f'/api/constituents/{self.cst1.id}/')
response = self.client.get(f'/api/constituents/{self.cst1.id}')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['alias'], self.cst1.alias)
self.assertEqual(response.data['convention'], self.cst1.convention)
def test_partial_update(self):
data = json.dumps({'convention': 'tt'})
response = self.client.patch(f'/api/constituents/{self.cst2.id}/', data, content_type='application/json')
response = self.client.patch(f'/api/constituents/{self.cst2.id}', data, content_type='application/json')
self.assertEqual(response.status_code, 403)
self.client.logout()
response = self.client.patch(f'/api/constituents/{self.cst1.id}/', data, content_type='application/json')
response = self.client.patch(f'/api/constituents/{self.cst1.id}', data, content_type='application/json')
self.assertEqual(response.status_code, 403)
self.client.force_authenticate(user=self.user)
response = self.client.patch(f'/api/constituents/{self.cst1.id}/', data, content_type='application/json')
response = self.client.patch(f'/api/constituents/{self.cst1.id}', data, content_type='application/json')
self.assertEqual(response.status_code, 200)
self.cst1.refresh_from_db()
self.assertEqual(response.data['convention'], 'tt')
self.assertEqual(self.cst1.convention, 'tt')
response = self.client.patch(f'/api/constituents/{self.cst1.id}/', data, content_type='application/json')
response = self.client.patch(f'/api/constituents/{self.cst1.id}', data, content_type='application/json')
self.assertEqual(response.status_code, 200)
def test_update_resolved_norefs(self):
@ -72,7 +72,7 @@ class TestConstituentaAPI(APITestCase):
'term_raw': 'New term',
'definition_raw': 'New def'
})
response = self.client.patch(f'/api/constituents/{self.cst3.id}/', data, content_type='application/json')
response = self.client.patch(f'/api/constituents/{self.cst3.id}', data, content_type='application/json')
self.assertEqual(response.status_code, 200)
self.cst3.refresh_from_db()
self.assertEqual(response.data['term_resolved'], 'New term')
@ -85,7 +85,7 @@ class TestConstituentaAPI(APITestCase):
'term_raw': '@{X1|nomn,sing}',
'definition_raw': '@{X1|nomn,sing} @{X1|sing,datv}'
})
response = self.client.patch(f'/api/constituents/{self.cst3.id}/', data, content_type='application/json')
response = self.client.patch(f'/api/constituents/{self.cst3.id}', data, content_type='application/json')
self.assertEqual(response.status_code, 200)
self.cst3.refresh_from_db()
self.assertEqual(self.cst3.term_resolved, self.cst1.term_resolved)
@ -95,7 +95,7 @@ class TestConstituentaAPI(APITestCase):
def test_readonly_cst_fields(self):
data = json.dumps({'alias': 'X33', 'order': 10})
response = self.client.patch(f'/api/constituents/{self.cst1.id}/', data, content_type='application/json')
response = self.client.patch(f'/api/constituents/{self.cst1.id}', data, content_type='application/json')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['alias'], 'X1')
self.assertEqual(response.data['alias'], self.cst1.alias)
@ -130,19 +130,19 @@ class TestLibraryViewset(APITestCase):
def test_create_anonymous(self):
self.client.logout()
data = json.dumps({'title': 'Title'})
response = self.client.post('/api/library/', data=data, content_type='application/json')
response = self.client.post('/api/library', data=data, content_type='application/json')
self.assertEqual(response.status_code, 403)
def test_create_populate_user(self):
data = json.dumps({'title': 'Title'})
response = self.client.post('/api/library/', data=data, content_type='application/json')
response = self.client.post('/api/library', data=data, content_type='application/json')
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data['title'], 'Title')
self.assertEqual(response.data['owner'], self.user.id)
def test_update(self):
data = json.dumps({'id': self.owned.id, 'title': 'New title'})
response = self.client.patch(f'/api/library/{self.owned.id}/',
response = self.client.patch(f'/api/library/{self.owned.id}',
data=data, content_type='application/json')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['title'], 'New title')
@ -150,61 +150,91 @@ class TestLibraryViewset(APITestCase):
def test_update_unowned(self):
data = json.dumps({'id': self.unowned.id, 'title': 'New title'})
response = self.client.patch(f'/api/library/{self.unowned.id}/',
response = self.client.patch(f'/api/library/{self.unowned.id}',
data=data, content_type='application/json')
self.assertEqual(response.status_code, 403)
def test_destroy(self):
response = self.client.delete(f'/api/library/{self.owned.id}/')
response = self.client.delete(f'/api/library/{self.owned.id}')
self.assertTrue(response.status_code in [202, 204])
def test_destroy_admin_override(self):
response = self.client.delete(f'/api/library/{self.unowned.id}/')
response = self.client.delete(f'/api/library/{self.unowned.id}')
self.assertEqual(response.status_code, 403)
self.user.is_staff = True
self.user.save()
response = self.client.delete(f'/api/library/{self.unowned.id}/')
response = self.client.delete(f'/api/library/{self.unowned.id}')
self.assertTrue(response.status_code in [202, 204])
def test_claim(self):
response = self.client.post(f'/api/library/{self.owned.id}/claim/')
response = self.client.post(f'/api/library/{self.owned.id}/claim')
self.assertEqual(response.status_code, 403)
self.owned.is_common = True
self.owned.save()
response = self.client.post(f'/api/library/{self.owned.id}/claim/')
response = self.client.post(f'/api/library/{self.owned.id}/claim')
self.assertEqual(response.status_code, 304)
response = self.client.post(f'/api/library/{self.unowned.id}/claim/')
response = self.client.post(f'/api/library/{self.unowned.id}/claim')
self.assertEqual(response.status_code, 403)
self.assertFalse(self.user in self.unowned.subscribers())
self.unowned.is_common = True
self.unowned.save()
response = self.client.post(f'/api/library/{self.unowned.id}/claim/')
response = self.client.post(f'/api/library/{self.unowned.id}/claim')
self.assertEqual(response.status_code, 200)
self.unowned.refresh_from_db()
self.assertEqual(self.unowned.owner, self.user)
self.assertEqual(self.unowned.owner, self.user)
self.assertTrue(self.user in self.unowned.subscribers())
def test_claim_anonymous(self):
self.client.logout()
response = self.client.post(f'/api/library/{self.owned.id}/claim/')
response = self.client.post(f'/api/library/{self.owned.id}/claim')
self.assertEqual(response.status_code, 403)
def test_retrieve_common(self):
self.client.logout()
response = self.client.get('/api/library/active/')
response = self.client.get('/api/library/active')
self.assertEqual(response.status_code, 200)
self.assertTrue(_response_contains(response, self.common))
self.assertFalse(_response_contains(response, self.unowned))
self.assertFalse(_response_contains(response, self.owned))
def test_retrieve_owned(self):
response = self.client.get('/api/library/active/')
response = self.client.get('/api/library/active')
self.assertEqual(response.status_code, 200)
self.assertTrue(_response_contains(response, self.common))
self.assertFalse(_response_contains(response, self.unowned))
self.assertTrue(_response_contains(response, self.owned))
def test_retrieve_subscribed(self):
response = self.client.get('/api/library/active')
self.assertEqual(response.status_code, 200)
self.assertFalse(_response_contains(response, self.unowned))
Subscription.subscribe(user=self.user, item=self.unowned)
response = self.client.get('/api/library/active')
self.assertEqual(response.status_code, 200)
self.assertTrue(_response_contains(response, self.unowned))
def test_subscriptions(self):
response = self.client.delete(f'/api/library/{self.unowned.id}/unsubscribe')
self.assertEqual(response.status_code, 200)
self.assertFalse(self.user in self.unowned.subscribers())
response = self.client.post(f'/api/library/{self.unowned.id}/subscribe')
self.assertEqual(response.status_code, 200)
self.assertTrue(self.user in self.unowned.subscribers())
response = self.client.post(f'/api/library/{self.unowned.id}/subscribe')
self.assertEqual(response.status_code, 200)
self.assertTrue(self.user in self.unowned.subscribers())
response = self.client.delete(f'/api/library/{self.unowned.id}/unsubscribe')
self.assertEqual(response.status_code, 200)
self.assertFalse(self.user in self.unowned.subscribers())
class TestRSFormViewset(APITestCase):
''' Testing RSForm view. '''
@ -221,13 +251,13 @@ class TestRSFormViewset(APITestCase):
item_type=LibraryItemType.OPERATIONS_SCHEMA,
title='Test3'
)
response = self.client.get('/api/rsforms/')
response = self.client.get('/api/rsforms')
self.assertEqual(response.status_code, 200)
self.assertFalse(_response_contains(response, non_schema))
self.assertTrue(_response_contains(response, self.unowned.item))
self.assertTrue(_response_contains(response, self.owned.item))
response = self.client.get('/api/library/')
response = self.client.get('/api/library')
self.assertEqual(response.status_code, 200)
self.assertTrue(_response_contains(response, non_schema))
self.assertTrue(_response_contains(response, self.unowned.item))
@ -236,11 +266,11 @@ class TestRSFormViewset(APITestCase):
def test_contents(self):
schema = RSForm.create(title='Title1')
schema.insert_last(alias='X1', insert_type=CstType.BASE)
response = self.client.get(f'/api/rsforms/{schema.item.id}/contents/')
response = self.client.get(f'/api/rsforms/{schema.item.id}/contents')
self.assertEqual(response.status_code, 200)
def test_details(self):
schema = RSForm.create(title='Test')
schema = RSForm.create(title='Test', owner=self.user)
x1 = schema.insert_at(1, 'X1', CstType.BASE)
x2 = schema.insert_at(2, 'X2', CstType.BASE)
x1.term_raw = 'человек'
@ -250,7 +280,7 @@ class TestRSFormViewset(APITestCase):
x1.save()
x2.save()
response = self.client.get(f'/api/rsforms/{schema.item.id}/details/')
response = self.client.get(f'/api/rsforms/{schema.item.id}/details')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['title'], 'Test')
@ -262,12 +292,13 @@ class TestRSFormViewset(APITestCase):
self.assertEqual(response.data['items'][1]['id'], x2.id)
self.assertEqual(response.data['items'][1]['term']['raw'], x2.term_raw)
self.assertEqual(response.data['items'][1]['term']['resolved'], x2.term_resolved)
self.assertEqual(response.data['subscribers'], [self.user.pk])
def test_check(self):
schema = RSForm.create(title='Test')
schema.insert_at(1, 'X1', CstType.BASE)
data = json.dumps({'expression': 'X1=X1'})
response = self.client.post(f'/api/rsforms/{schema.item.id}/check/', data=data, content_type='application/json')
response = self.client.post(f'/api/rsforms/{schema.item.id}/check', data=data, content_type='application/json')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['parseResult'], True)
self.assertEqual(response.data['syntax'], Syntax.MATH)
@ -281,7 +312,7 @@ class TestRSFormViewset(APITestCase):
x1.term_resolved = 'синий слон'
x1.save()
data = json.dumps({'text': '@{1|редкий} @{X1|plur,datv}'})
response = self.client.post(f'/api/rsforms/{schema.item.id}/resolve/', data=data, content_type='application/json')
response = self.client.post(f'/api/rsforms/{schema.item.id}/resolve', data=data, content_type='application/json')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['input'], '@{1|редкий} @{X1|plur,datv}')
self.assertEqual(response.data['output'], 'редким синим слонам')
@ -307,7 +338,7 @@ class TestRSFormViewset(APITestCase):
work_dir = os.path.dirname(os.path.abspath(__file__))
with open(f'{work_dir}/data/sample-rsform.trs', 'rb') as file:
data = {'file': file}
response = self.client.post('/api/rsforms/import-trs/', data=data, format='multipart')
response = self.client.post('/api/rsforms/import-trs', data=data, format='multipart')
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data['owner'], self.user.pk)
self.assertTrue(response.data['title'] != '')
@ -315,7 +346,7 @@ class TestRSFormViewset(APITestCase):
def test_export_trs(self):
schema = RSForm.create(title='Test')
schema.insert_at(1, 'X1', CstType.BASE)
response = self.client.get(f'/api/rsforms/{schema.item.id}/export-trs/')
response = self.client.get(f'/api/rsforms/{schema.item.id}/export-trs')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers['Content-Disposition'], 'attachment; filename=Schema.trs')
with io.BytesIO(response.content) as stream:
@ -325,14 +356,14 @@ class TestRSFormViewset(APITestCase):
def test_create_constituenta(self):
data = json.dumps({'alias': 'X3', 'cst_type': 'basic'})
response = self.client.post(f'/api/rsforms/{self.unowned.item.id}/cst-create/',
response = self.client.post(f'/api/rsforms/{self.unowned.item.id}/cst-create',
data=data, content_type='application/json')
self.assertEqual(response.status_code, 403)
item = self.owned.item
Constituenta.objects.create(schema=item, alias='X1', cst_type='basic', order=1)
x2 = Constituenta.objects.create(schema=item, alias='X2', cst_type='basic', order=2)
response = self.client.post(f'/api/rsforms/{item.id}/cst-create/',
response = self.client.post(f'/api/rsforms/{item.id}/cst-create',
data=data, content_type='application/json')
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data['new_cst']['alias'], 'X3')
@ -340,7 +371,7 @@ class TestRSFormViewset(APITestCase):
self.assertEqual(x3.order, 3)
data = json.dumps({'alias': 'X4', 'cst_type': 'basic', 'insert_after': x2.id})
response = self.client.post(f'/api/rsforms/{item.id}/cst-create/',
response = self.client.post(f'/api/rsforms/{item.id}/cst-create',
data=data, content_type='application/json')
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data['new_cst']['alias'], 'X4')
@ -361,16 +392,16 @@ class TestRSFormViewset(APITestCase):
definition_raw='Test1', definition_resolved='Test2')
data = json.dumps({'alias': 'D2', 'cst_type': 'term', 'id': self.cst2.pk})
response = self.client.patch(f'/api/rsforms/{self.unowned.item.id}/cst-rename/',
response = self.client.patch(f'/api/rsforms/{self.unowned.item.id}/cst-rename',
data=data, content_type='application/json')
self.assertEqual(response.status_code, 403)
response = self.client.patch(f'/api/rsforms/{self.owned.item.id}/cst-rename/',
response = self.client.patch(f'/api/rsforms/{self.owned.item.id}/cst-rename',
data=data, content_type='application/json')
self.assertEqual(response.status_code, 400)
data = json.dumps({'alias': self.cst1.alias, 'cst_type': 'term', 'id': self.cst1.pk})
response = self.client.patch(f'/api/rsforms/{self.owned.item.id}/cst-rename/',
response = self.client.patch(f'/api/rsforms/{self.owned.item.id}/cst-rename',
data=data, content_type='application/json')
self.assertEqual(response.status_code, 400)
@ -384,7 +415,7 @@ class TestRSFormViewset(APITestCase):
self.assertEqual(self.cst1.order, 1)
self.assertEqual(self.cst1.alias, 'X1')
self.assertEqual(self.cst1.cst_type, CstType.BASE)
response = self.client.patch(f'/api/rsforms/{item.id}/cst-rename/',
response = self.client.patch(f'/api/rsforms/{item.id}/cst-rename',
data=data, content_type='application/json')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['new_cst']['alias'], 'D2')
@ -407,7 +438,7 @@ class TestRSFormViewset(APITestCase):
'definition_raw': '4'
})
item = self.owned.item
response = self.client.post(f'/api/rsforms/{item.id}/cst-create/',
response = self.client.post(f'/api/rsforms/{item.id}/cst-create',
data=data, content_type='application/json')
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data['new_cst']['alias'], 'X3')
@ -422,14 +453,14 @@ class TestRSFormViewset(APITestCase):
def test_delete_constituenta(self):
schema = self.owned
data = json.dumps({'items': [{'id': 1337}]})
response = self.client.patch(f'/api/rsforms/{schema.item.id}/cst-multidelete/',
response = self.client.patch(f'/api/rsforms/{schema.item.id}/cst-multidelete',
data=data, content_type='application/json')
self.assertEqual(response.status_code, 400)
x1 = Constituenta.objects.create(schema=schema.item, alias='X1', cst_type='basic', order=1)
x2 = Constituenta.objects.create(schema=schema.item, alias='X2', cst_type='basic', order=2)
data = json.dumps({'items': [{'id': x1.id}]})
response = self.client.patch(f'/api/rsforms/{schema.item.id}/cst-multidelete/',
response = self.client.patch(f'/api/rsforms/{schema.item.id}/cst-multidelete',
data=data, content_type='application/json')
x2.refresh_from_db()
schema.item.refresh_from_db()
@ -441,21 +472,21 @@ class TestRSFormViewset(APITestCase):
x3 = Constituenta.objects.create(schema=self.unowned.item, alias='X1', cst_type='basic', order=1)
data = json.dumps({'items': [{'id': x3.id}]})
response = self.client.patch(f'/api/rsforms/{schema.item.id}/cst-multidelete/',
response = self.client.patch(f'/api/rsforms/{schema.item.id}/cst-multidelete',
data=data, content_type='application/json')
self.assertEqual(response.status_code, 400)
def test_move_constituenta(self):
item = self.owned.item
data = json.dumps({'items': [{'id': 1337}], 'move_to': 1})
response = self.client.patch(f'/api/rsforms/{item.id}/cst-moveto/',
response = self.client.patch(f'/api/rsforms/{item.id}/cst-moveto',
data=data, content_type='application/json')
self.assertEqual(response.status_code, 400)
x1 = Constituenta.objects.create(schema=item, alias='X1', cst_type='basic', order=1)
x2 = Constituenta.objects.create(schema=item, alias='X2', cst_type='basic', order=2)
data = json.dumps({'items': [{'id': x2.id}], 'move_to': 1})
response = self.client.patch(f'/api/rsforms/{item.id}/cst-moveto/',
response = self.client.patch(f'/api/rsforms/{item.id}/cst-moveto',
data=data, content_type='application/json')
x1.refresh_from_db()
x2.refresh_from_db()
@ -466,20 +497,20 @@ class TestRSFormViewset(APITestCase):
x3 = Constituenta.objects.create(schema=self.unowned.item, alias='X1', cst_type='basic', order=1)
data = json.dumps({'items': [{'id': x3.id}], 'move_to': 1})
response = self.client.patch(f'/api/rsforms/{item.id}/cst-moveto/',
response = self.client.patch(f'/api/rsforms/{item.id}/cst-moveto',
data=data, content_type='application/json')
self.assertEqual(response.status_code, 400)
def test_reset_aliases(self):
item = self.owned.item
response = self.client.patch(f'/api/rsforms/{item.id}/reset-aliases/')
response = self.client.patch(f'/api/rsforms/{item.id}/reset-aliases')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['id'], item.id)
x2 = Constituenta.objects.create(schema=item, alias='X2', cst_type='basic', order=1)
x1 = Constituenta.objects.create(schema=item, alias='X1', cst_type='basic', order=2)
d11 = Constituenta.objects.create(schema=item, alias='D11', cst_type='term', order=3)
response = self.client.patch(f'/api/rsforms/{item.id}/reset-aliases/')
response = self.client.patch(f'/api/rsforms/{item.id}/reset-aliases')
x1.refresh_from_db()
x2.refresh_from_db()
d11.refresh_from_db()
@ -491,7 +522,7 @@ class TestRSFormViewset(APITestCase):
self.assertEqual(d11.order, 3)
self.assertEqual(d11.alias, 'D1')
response = self.client.patch(f'/api/rsforms/{item.id}/reset-aliases/')
response = self.client.patch(f'/api/rsforms/{item.id}/reset-aliases')
self.assertEqual(response.status_code, 200)
def test_load_trs(self):
@ -502,13 +533,13 @@ class TestRSFormViewset(APITestCase):
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.item.id}/load-trs/', data=data, format='multipart')
response = self.client.patch(f'/api/rsforms/{schema.item.id}/load-trs', data=data, format='multipart')
schema.item.refresh_from_db()
self.assertEqual(response.status_code, 200)
self.assertEqual(schema.item.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())
self.assertFalse(Constituenta.objects.filter(pk=x1.id).exists())
def test_clone(self):
item = self.owned.item
@ -524,7 +555,7 @@ class TestRSFormViewset(APITestCase):
d1.save()
data = json.dumps({'title': 'Title'})
response = self.client.post(f'/api/library/{item.id}/clone/', data=data, content_type='application/json')
response = self.client.post(f'/api/library/{item.id}/clone', data=data, content_type='application/json')
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data['title'], 'Title')
@ -546,7 +577,7 @@ class TestFunctionalViews(APITestCase):
work_dir = os.path.dirname(os.path.abspath(__file__))
with open(f'{work_dir}/data/sample-rsform.trs', 'rb') as file:
data = {'file': file, 'title': 'Test123', 'comment': '123', 'alias': 'ks1'}
response = self.client.post('/api/rsforms/create-detailed/', data=data, format='multipart')
response = self.client.post('/api/rsforms/create-detailed', data=data, format='multipart')
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data['owner'], self.user.pk)
self.assertEqual(response.data['title'], 'Test123')
@ -555,7 +586,7 @@ class TestFunctionalViews(APITestCase):
def test_create_rsform_fallback(self):
data = {'title': 'Test123', 'comment': '123', 'alias': 'ks1'}
response = self.client.post('/api/rsforms/create-detailed/', data=data)
response = self.client.post('/api/rsforms/create-detailed', data=data)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data['owner'], self.user.pk)
self.assertEqual(response.data['title'], 'Test123')

View File

@ -3,17 +3,17 @@ from django.urls import path, include
from rest_framework import routers
from . import views
library_router = routers.SimpleRouter()
library_router = routers.SimpleRouter(trailing_slash=False)
library_router.register('library', views.LibraryViewSet)
library_router.register('rsforms', views.RSFormViewSet)
urlpatterns = [
path('library/active/', views.LibraryActiveView.as_view(), name='library'),
path('constituents/<int:pk>/', views.ConstituentAPIView.as_view(), name='constituenta-detail'),
path('rsforms/import-trs/', views.TrsImportView.as_view()),
path('rsforms/create-detailed/', views.create_rsform),
path('func/parse-expression/', views.parse_expression),
path('func/to-ascii/', views.convert_to_ascii),
path('func/to-math/', views.convert_to_math),
path('library/active', views.LibraryActiveView.as_view(), name='library'),
path('constituents/<int:pk>', views.ConstituentAPIView.as_view(), name='constituenta-detail'),
path('rsforms/import-trs', views.TrsImportView.as_view()),
path('rsforms/create-detailed', views.create_rsform),
path('func/parse-expression', views.parse_expression),
path('func/to-ascii', views.convert_to_ascii),
path('func/to-math', views.convert_to_math),
path('', include(library_router.urls)),
]

View File

@ -24,7 +24,8 @@ class LibraryActiveView(generics.ListAPIView):
def get_queryset(self):
user = self.request.user
if not user.is_anonymous:
return m.LibraryItem.objects.filter(Q(is_common=True) | Q(owner=user))
# pyling: disable=unsupported-binary-operation
return m.LibraryItem.objects.filter(Q(is_common=True) | Q(owner=user) | Q(subscription__user=user))
else:
return m.LibraryItem.objects.filter(is_common=True)
@ -62,7 +63,7 @@ class LibraryViewSet(viewsets.ModelViewSet):
def get_permissions(self):
if self.action in ['update', 'destroy', 'partial_update']:
permission_classes = [utils.ObjectOwnerOrAdmin]
elif self.action in ['create', 'clone']:
elif self.action in ['create', 'clone', 'subscribe', 'unsubscribe']:
permission_classes = [permissions.IsAuthenticated]
elif self.action in ['claim']:
permission_classes = [utils.IsClaimable]
@ -70,13 +71,16 @@ class LibraryViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.AllowAny]
return [permission() for permission in permission_classes]
def _get_item(self) -> m.LibraryItem:
return cast(m.LibraryItem, self.get_object())
@transaction.atomic
@action(detail=True, methods=['post'], url_path='clone')
def clone(self, request, pk):
''' Endpoint: Create deep copy of library item. '''
serializer = s.LibraryItemSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
item = cast(m.LibraryItem, self.get_object())
item = self._get_item()
if item.item_type == m.LibraryItemType.RSFORM:
schema = m.RSForm(item)
clone_data = s.RSFormTRSSerializer(schema).data
@ -93,21 +97,37 @@ class LibraryViewSet(viewsets.ModelViewSet):
return Response(status=201, data=m.PyConceptAdapter(new_schema).full())
return Response(status=404)
@transaction.atomic
@action(detail=True, methods=['post'])
def claim(self, request, pk=None):
''' Endpoint: Claim ownership of LibraryItem. '''
item = cast(m.LibraryItem, self.get_object())
item = self._get_item()
if item.owner == self.request.user:
return Response(status=304)
else:
item.owner = self.request.user
item.save()
m.Subscription.subscribe(user=item.owner, item=item)
return Response(status=200, data=s.LibraryItemSerializer(item).data)
@action(detail=True, methods=['post'])
def subscribe(self, request, pk):
''' Endpoint: Subscribe current user to item. '''
item = self._get_item()
m.Subscription.subscribe(user=self.request.user, item=item)
return Response(status=200)
@action(detail=True, methods=['delete'])
def unsubscribe(self, request, pk):
''' Endpoint: Unsubscribe current user from item. '''
item = self._get_item()
m.Subscription.unsubscribe(user=self.request.user, item=item)
return Response(status=200)
class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.RetrieveAPIView):
''' Endpoint: RSForm operations. '''
queryset = m.LibraryItem.objects.all().filter(item_type=m.LibraryItemType.RSFORM)
queryset = m.LibraryItem.objects.filter(item_type=m.LibraryItemType.RSFORM)
serializer_class = s.LibraryItemSerializer
def _get_schema(self) -> m.RSForm:

View File

@ -3,6 +3,7 @@ from django.contrib.auth import authenticate
from django.contrib.auth.password_validation import validate_password
from rest_framework import serializers
from apps.rsform.models import Subscription
from . import models
@ -40,16 +41,23 @@ class LoginSerializer(serializers.Serializer):
raise NotImplementedError('unexpected `update()` call')
class AuthSerializer(serializers.ModelSerializer):
class AuthSerializer(serializers.Serializer):
''' Serializer: Authentication data. '''
class Meta:
''' serializer metadata. '''
model = models.User
fields = [
'id',
'username',
'is_staff'
]
def to_representation(self, instance: models.User) -> dict:
if instance.is_anonymous:
return {
'id': None,
'username': '',
'is_staff': False,
'subscriptions': []
}
else:
return {
'id': instance.pk,
'username': instance.username,
'is_staff': instance.is_staff,
'subscriptions': [sub.item.pk for sub in Subscription.objects.filter(user=instance)]
}
class UserInfoSerializer(serializers.ModelSerializer):

View File

@ -3,9 +3,10 @@ import json
from rest_framework.test import APITestCase, APIClient
from apps.users.models import User
from apps.rsform.models import LibraryItem, LibraryItemType
# TODO: test AUTH and ATIVE_USERS
# TODO: test ACTIVE_USERS
class TestUserAPIViews(APITestCase):
def setUp(self):
self.username = 'UserTest'
@ -30,6 +31,30 @@ class TestUserAPIViews(APITestCase):
self.assertEqual(self.client.post('/users/api/logout').status_code, 403)
def test_auth(self):
LibraryItem.objects.create(item_type=LibraryItemType.RSFORM, title='T1')
item = LibraryItem.objects.create(
item_type=LibraryItemType.RSFORM,
title='Test',
alias='T1',
is_common=True,
owner=self.user
)
response = self.client.get('/users/api/auth')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['id'], None)
self.assertEqual(response.data['username'], '')
self.assertEqual(response.data['is_staff'], False)
self.assertEqual(response.data['subscriptions'], [])
self.client.force_login(self.user)
response = self.client.get('/users/api/auth')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['id'], self.user.pk)
self.assertEqual(response.data['username'], self.user.username)
self.assertEqual(response.data['is_staff'], self.user.is_staff)
self.assertEqual(response.data['subscriptions'], [item.pk])
class TestUserUserProfileAPIView(APITestCase):
def setUp(self):

View File

@ -8,9 +8,7 @@ from . import serializers
from . import models
class LoginAPIView(views.APIView):
'''
Endpoint: Login user via username + password.
'''
''' Endpoint: Login via username + password. '''
permission_classes = (permissions.AllowAny,)
def post(self, request):
@ -25,9 +23,7 @@ class LoginAPIView(views.APIView):
class LogoutAPIView(views.APIView):
'''
Endpoint: Logout current user.
'''
''' Endpoint: Logout. '''
permission_classes = (permissions.IsAuthenticated,)
def post(self, request):
@ -36,17 +32,13 @@ class LogoutAPIView(views.APIView):
class SignupAPIView(generics.CreateAPIView):
'''
Register user.
'''
''' Endpoint: Register user. '''
permission_classes = (permissions.AllowAny, )
serializer_class = serializers.SignupSerializer
class AuthAPIView(generics.RetrieveAPIView):
'''
Get current user authentification ID.
'''
''' Endpoint: Current user info. '''
permission_classes = (permissions.AllowAny,)
serializer_class = serializers.AuthSerializer
@ -55,9 +47,7 @@ class AuthAPIView(generics.RetrieveAPIView):
class ActiveUsersView(generics.ListAPIView):
'''
Endpoint: Get list of active users.
'''
''' Endpoint: Get list of active users. '''
permission_classes = (permissions.AllowAny,)
serializer_class = serializers.UserSerializer
@ -66,9 +56,7 @@ class ActiveUsersView(generics.ListAPIView):
class UserProfileAPIView(generics.RetrieveUpdateAPIView):
'''
Endpoint: User profile info.
'''
''' Endpoint: User profile. '''
permission_classes = (permissions.IsAuthenticated,)
serializer_class = serializers.UserSerializer
@ -77,9 +65,7 @@ class UserProfileAPIView(generics.RetrieveUpdateAPIView):
class UpdatePassword(views.APIView):
'''
Endpoint: Change password for current user.
'''
''' Endpoint: Change password for current user. '''
permission_classes = (permissions.IsAuthenticated, )
def get_object(self, queryset=None):

View File

@ -159,7 +159,7 @@
"alias": "M0005",
"comment": "",
"is_common": true,
"is_canonical": true,
"is_canonical": false,
"time_create": "2023-08-25T19:03:40.052Z",
"time_update": "2023-08-25T19:03:40.052Z"
}
@ -174,7 +174,7 @@
"alias": "M0006",
"comment": "",
"is_common": true,
"is_canonical": true,
"is_canonical": false,
"time_create": "2023-08-25T19:03:40.066Z",
"time_update": "2023-08-25T19:03:40.066Z"
}
@ -189,7 +189,7 @@
"alias": "M0007",
"comment": "",
"is_common": true,
"is_canonical": true,
"is_canonical": false,
"time_create": "2023-08-25T19:03:40.077Z",
"time_update": "2023-08-25T19:03:40.077Z"
}
@ -204,7 +204,7 @@
"alias": "M0008",
"comment": "",
"is_common": true,
"is_canonical": true,
"is_canonical": false,
"time_create": "2023-08-25T19:03:40.081Z",
"time_update": "2023-08-25T19:03:40.081Z"
}
@ -219,7 +219,7 @@
"alias": "M0009",
"comment": "",
"is_common": true,
"is_canonical": true,
"is_canonical": false,
"time_create": "2023-08-25T19:03:40.094Z",
"time_update": "2023-08-25T19:03:40.095Z"
}
@ -234,7 +234,7 @@
"alias": "M0010",
"comment": "",
"is_common": true,
"is_canonical": true,
"is_canonical": false,
"time_create": "2023-08-25T19:03:40.118Z",
"time_update": "2023-08-25T19:03:40.118Z"
}
@ -249,7 +249,7 @@
"alias": "M0011",
"comment": "",
"is_common": true,
"is_canonical": true,
"is_canonical": false,
"time_create": "2023-08-25T19:03:40.132Z",
"time_update": "2023-08-25T19:03:40.132Z"
}
@ -264,7 +264,7 @@
"alias": "M0012",
"comment": "",
"is_common": true,
"is_canonical": true,
"is_canonical": false,
"time_create": "2023-08-25T19:03:40.152Z",
"time_update": "2023-08-25T19:03:40.152Z"
}
@ -279,7 +279,7 @@
"alias": "M0013",
"comment": "",
"is_common": true,
"is_canonical": true,
"is_canonical": false,
"time_create": "2023-08-25T19:03:40.167Z",
"time_update": "2023-08-25T19:03:40.168Z"
}
@ -294,7 +294,7 @@
"alias": "M0014",
"comment": "",
"is_common": true,
"is_canonical": true,
"is_canonical": false,
"time_create": "2023-08-25T19:03:40.196Z",
"time_update": "2023-08-25T19:03:40.196Z"
}
@ -309,7 +309,7 @@
"alias": "M0015",
"comment": "",
"is_common": true,
"is_canonical": true,
"is_canonical": false,
"time_create": "2023-08-25T19:03:40.207Z",
"time_update": "2023-08-25T19:03:40.207Z"
}
@ -324,7 +324,7 @@
"alias": "M0016",
"comment": "",
"is_common": true,
"is_canonical": true,
"is_canonical": false,
"time_create": "2023-08-25T19:03:40.258Z",
"time_update": "2023-08-25T19:03:40.258Z"
}
@ -354,7 +354,7 @@
"alias": "D0002",
"comment": "",
"is_common": true,
"is_canonical": true,
"is_canonical": false,
"time_create": "2023-08-25T19:03:40.459Z",
"time_update": "2023-08-25T19:03:40.460Z"
}

View File

@ -3,14 +3,15 @@ import { TextareaHTMLAttributes } from 'react';
import Label from './Label';
export interface TextAreaProps
extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'className'> {
extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'className' | 'title'> {
label: string
tooltip?: string
widthClass?: string
colorClass?: string
}
function TextArea({
id, label, required,
id, label, required, tooltip,
widthClass = 'w-full',
colorClass = 'clr-input',
rows = 4,
@ -24,10 +25,11 @@ function TextArea({
htmlFor={id}
/>
<textarea id={id}
className={`px-3 py-2 mt-2 leading-tight border shadow ${colorClass} ${widthClass}`}
rows={rows}
required={required}
{...props}
title={tooltip}
className={`px-3 py-2 mt-2 leading-tight border shadow ${colorClass} ${widthClass}`}
rows={rows}
required={required}
{...props}
/>
</div>
);

View File

@ -3,16 +3,17 @@ import { type InputHTMLAttributes } from 'react';
import Label from './Label';
interface TextInputProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'className'> {
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'className' | 'title'> {
id: string
label: string
tooltip?: string
widthClass?: string
colorClass?: string
singleRow?: boolean
}
function TextInput({
id, required, label, singleRow,
id, required, label, singleRow, tooltip,
widthClass = 'w-full',
colorClass = 'clr-input',
...props
@ -25,6 +26,7 @@ function TextInput({
htmlFor={id}
/>
<input id={id}
title={tooltip}
className={`px-3 py-2 mt-2 leading-tight border shadow truncate hover:text-clip ${colorClass} ${singleRow ? '' : widthClass}`}
required={required}
{...props}

View File

@ -44,7 +44,9 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
(params: ILibraryFilter) => {
let result = items;
if (params.ownedBy) {
result = result.filter(item => item.owner === params.ownedBy);
result = result.filter(item =>
item.owner === params.ownedBy
|| user?.subscriptions.includes(item.id));
}
if (params.is_common !== undefined) {
result = result.filter(item => item.is_common === params.is_common);
@ -53,7 +55,7 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
result = result.filter(item => matchLibraryItem(params.queryMeta!, item));
}
return result;
}, [items]);
}, [items, user]);
const reload = useCallback(
(callback?: () => void) => {

View File

@ -1,15 +1,14 @@
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
import { toast } from 'react-toastify'
import { type ErrorInfo } from '../components/BackendError'
import { useRSFormDetails } from '../hooks/useRSFormDetails'
import {
type DataCallback, getTRSFile,
type DataCallback, deleteUnsubscribe,
getTRSFile,
patchConstituenta, patchDeleteConstituenta,
patchLibraryItem,
patchMoveConstituenta, patchRenameConstituenta,
patchResetAliases, patchUploadTRS, postClaimLibraryItem, postNewConstituenta
} from '../utils/backendAPI'
patchResetAliases, patchUploadTRS, postClaimLibraryItem, postNewConstituenta, postSubscribe} from '../utils/backendAPI'
import {
IConstituentaList, IConstituentaMeta, ICstCreateData,
ICstMovetoData, ICstRenameData, ICstUpdateData, ILibraryItem,
@ -33,10 +32,11 @@ interface IRSFormContext {
toggleForceAdmin: () => void
toggleReadonly: () => void
toggleTracking: () => void
update: (data: ILibraryUpdateData, callback?: DataCallback<ILibraryItem>) => void
claim: (callback?: DataCallback<ILibraryItem>) => void
subscribe: (callback?: () => void) => void
unsubscribe: (callback?: () => void) => void
download: (callback: DataCallback<Blob>) => void
upload: (data: IRSFormUploadData, callback: () => void) => void
@ -72,6 +72,7 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
const [ isForceAdmin, setIsForceAdmin ] = useState(false);
const [ isReadonly, setIsReadonly ] = useState(false);
const [ toggleTracking, setToggleTracking ] = useState(false);
const isOwned = useMemo(
() => {
@ -93,13 +94,12 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
const isTracking = useMemo(
() => {
return true;
}, []);
const toggleTracking = useCallback(
() => {
toast.info('Отслеживание в разработке...')
}, []);
if (!schema || !user) {
return false;
}
return schema.subscribers.includes(user.id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, schema, toggleTracking]);
const update = useCallback(
(data: ILibraryUpdateData, callback?: DataCallback<ILibraryItem>) => {
@ -153,6 +153,52 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
}
});
}, [schemaID, setError, schema, user, setSchema]);
const subscribe = useCallback(
(callback?: () => void) => {
if (!schema || !user) {
return;
}
setError(undefined)
postSubscribe(schemaID, {
showError: true,
setLoading: setProcessing,
onError: error => setError(error),
onSuccess: () => {
if (!schema.subscribers.includes(user.id)) {
schema.subscribers.push(user.id);
}
if (!user.subscriptions.includes(schema.id)) {
user.subscriptions.push(schema.id);
}
setToggleTracking(prev => !prev);
if (callback) callback();
}
});
}, [schemaID, setError, schema, user]);
const unsubscribe = useCallback(
(callback?: () => void) => {
if (!schema || !user) {
return;
}
setError(undefined)
deleteUnsubscribe(schemaID, {
showError: true,
setLoading: setProcessing,
onError: error => setError(error),
onSuccess: () => {
if (schema.subscribers.includes(user.id)) {
schema.subscribers.splice(schema.subscribers.indexOf(user.id), 1);
}
if (user.subscriptions.includes(schema.id)) {
user.subscriptions.splice(user.subscriptions.indexOf(schema.id), 1);
}
setToggleTracking(prev => !prev);
if (callback) callback();
}
});
}, [schemaID, setError, schema, user]);
const resetAliases = useCallback(
(callback?: () => void) => {
@ -264,8 +310,7 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
isClaimable, isTracking,
toggleForceAdmin: () => setIsForceAdmin(prev => !prev),
toggleReadonly: () => setIsReadonly(prev => !prev),
toggleTracking,
update, download, upload, claim, resetAliases,
update, download, upload, claim, resetAliases, subscribe, unsubscribe,
cstUpdate, cstCreate, cstRename, cstDelete, cstMoveTo
}}>
{ children }

View File

@ -110,6 +110,10 @@
@apply bg-white dark:bg-gray-900 checked:bg-blue-700 dark:checked:bg-orange-500
}
.clr-input-red {
@apply bg-red-300 dark:bg-red-700
}
.text-url {
@apply hover:text-blue-600 text-blue-400 dark:text-orange-600 dark:hover:text-orange-400
}

View File

@ -4,8 +4,11 @@ import { useNavigate } from 'react-router-dom';
import ConceptDataTable from '../../components/Common/ConceptDataTable';
import TextURL from '../../components/Common/TextURL';
import { EducationIcon, EyeIcon, GroupIcon } from '../../components/Icons';
import { useAuth } from '../../context/AuthContext';
import { useNavSearch } from '../../context/NavSearchContext';
import { useUsers } from '../../context/UsersContext';
import { prefixes } from '../../utils/constants';
import { ILibraryItem } from '../../utils/models'
interface ViewLibraryProps {
@ -14,14 +17,35 @@ interface ViewLibraryProps {
function ViewLibrary({ items }: ViewLibraryProps) {
const { resetQuery: cleanQuery } = useNavSearch();
const { user } = useAuth();
const navigate = useNavigate();
const intl = useIntl();
const { getUserLabel } = useUsers();
const openRSForm = (item: ILibraryItem) => navigate(`/rsforms/${item.id}`);
const columns = useMemo(() =>
[
const columns = useMemo(
() => [
{
name: '',
id: 'status',
minWidth: '60px',
maxWidth: '60px',
cell: (item: ILibraryItem) => {
return (<>
<div
className='flex items-center justify-start gap-1'
id={`${prefixes.library_list}${item.id}`}
>
{user && user.subscriptions.includes(item.id) && <EyeIcon size={3}/>}
{item.is_common && <GroupIcon size={3}/>}
{item.is_canonical && <EducationIcon size={3}/>}
</div>
</>);
},
sortable: true,
reorder: true
},
{
name: 'Шифр',
id: 'alias',
@ -30,16 +54,6 @@ function ViewLibrary({ items }: ViewLibraryProps) {
sortable: true,
reorder: true
},
{
name: 'Статусы',
id: 'status',
maxWidth: '50px',
selector: (item: ILibraryItem) => {
return `${item.is_canonical ? 'C': ''}${item.is_common ? 'S': ''}`
},
sortable: true,
reorder: true
},
{
name: 'Название',
id: 'title',
@ -66,7 +80,7 @@ function ViewLibrary({ items }: ViewLibraryProps) {
sortable: true,
reorder: true
}
], [intl, getUserLabel]);
], [intl, getUserLabel, user]);
return (
<ConceptDataTable

View File

@ -175,7 +175,6 @@ function EditorItems({ onOpenEdit, onCreateCst, onDeleteCst }: EditorItemsProps)
{
name: 'Имя',
id: 'alias',
selector: (cst: IConstituenta) => cst.alias,
cell: (cst: IConstituenta) => {
const info = mapStatusInfo.get(cst.status)!;
return (<>

View File

@ -154,13 +154,19 @@ function EditorRSForm({ onDestroy, onClaim, onShare, onDownload }: EditorRSFormP
icon={<SaveIcon size={6} />}
/>
</div>
<div className='flex justify-start mt-2'>
<label className='font-semibold'>Владелец:</label>
<span className='min-w-[200px] ml-2 overflow-ellipsis overflow-hidden whitespace-nowrap'>
{getUserLabel(schema?.owner ?? null)}
</span>
</div>
<div className='flex justify-start mt-2'>
<label className='font-semibold'>Отслеживают:</label>
<span id='subscriber-count' className='ml-2'>
{ schema?.subscribers.length ?? 0 }
</span>
</div>
<div className='flex justify-start mt-2'>
<label className='font-semibold'>Дата обновления:</label>
<span className='ml-2'>{schema && new Date(schema?.time_update).toLocaleString(intl.locale)}</span>

View File

@ -176,7 +176,6 @@ function EditorTermGraph({ onOpenEdit, onCreateCst, onDeleteCst }: EditorTermGra
});
}
});
console.log(result);
return result;
}, [schema, coloringScheme, filtered.nodes, darkMode, noTerms]);
@ -194,7 +193,6 @@ function EditorTermGraph({ onOpenEdit, onCreateCst, onDeleteCst }: EditorTermGra
edgeID += 1;
});
});
console.log(result);
return result;
}, [filtered.nodes]);
@ -256,7 +254,6 @@ function EditorTermGraph({ onOpenEdit, onCreateCst, onDeleteCst }: EditorTermGra
// Implement hotkeys for editing
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
console.log(event);
if (!isEditable) {
return;
}

View File

@ -36,8 +36,8 @@ function RSTabs() {
const navigate = useNavigate();
const search = useLocation().search;
const {
error, schema, loading, claim, download,
cstCreate, cstDelete, cstRename
error, schema, loading, claim, download, isTracking,
cstCreate, cstDelete, cstRename, subscribe, unsubscribe
} = useRSForm();
const { destroySchema } = useLibrary();
@ -224,6 +224,21 @@ function RSTabs() {
}
});
}, [schema?.alias, download]);
const handleToggleSubscribe = useCallback(
() => {
if (isTracking) {
unsubscribe(
() => {
toast.success('Отслеживание отключено');
});
} else {
subscribe(
() => {
toast.success('Отслеживание включено');
});
}
}, [isTracking, subscribe, unsubscribe]);
return (
<div className='w-full'>
@ -274,6 +289,7 @@ function RSTabs() {
onDestroy={onDestroySchema}
onClaim={onClaimSchema}
onShare={onShareSchema}
onToggleSubscribe={handleToggleSubscribe}
showCloneDialog={() => setShowClone(true)}
showUploadDialog={() => setShowUpload(true)}
/>

View File

@ -16,17 +16,18 @@ interface RSTabsMenuProps {
onClaim: () => void
onShare: () => void
onDownload: () => void
onToggleSubscribe: () => void
}
function RSTabsMenu({
showUploadDialog, showCloneDialog,
onDestroy, onShare, onDownload, onClaim
onDestroy, onShare, onDownload, onClaim, onToggleSubscribe
}: RSTabsMenuProps) {
const navigate = useNavigate();
const { user } = useAuth();
const {
isOwned, isEditable, isTracking, isReadonly: readonly, isForceAdmin: forceAdmin,
toggleTracking, toggleForceAdmin, toggleReadonly
isOwned, isEditable, isTracking, isReadonly, isClaimable, isForceAdmin,
toggleForceAdmin, toggleReadonly, processing
} = useRSForm();
const schemaMenu = useDropdown();
const editMenu = useDropdown();
@ -127,7 +128,11 @@ function RSTabsMenu({
/>
{ editMenu.isActive &&
<Dropdown>
<DropdownButton disabled={!user} onClick={!isOwned ? handleClaimOwner : undefined}>
<DropdownButton
disabled={!user || !isClaimable}
onClick={!isOwned ? handleClaimOwner : undefined}
description={!user || !isClaimable ? 'Стать владельцем можно только для общей небиблиотечной схемы' : ''}
>
<div className='inline-flex items-center gap-1 justify-normal'>
<span className={isOwned ? 'text-green' : ''}><CrownIcon size={4} /></span>
<p>
@ -139,20 +144,21 @@ function RSTabsMenu({
{(isOwned || user?.is_staff) &&
<DropdownButton onClick={toggleReadonly}>
<Checkbox
value={readonly}
value={isReadonly}
label='Я — читатель!'
tooltip='Режим чтения'
/>
</DropdownButton>}
{user?.is_staff &&
<DropdownButton onClick={toggleForceAdmin}>
<Checkbox value={forceAdmin} label='режим администратора'/>
<Checkbox value={isForceAdmin} label='режим администратора'/>
</DropdownButton>}
</Dropdown>}
</div>
<div>
<Button
tooltip={'отслеживание: ' + (isTracking ? '[включено]' : '[выключено]')}
disabled={processing}
icon={isTracking
? <EyeIcon color='text-primary' size={5}/>
: <EyeOffIcon size={5}/>
@ -160,7 +166,7 @@ function RSTabsMenu({
widthClass='h-full w-fit'
borderClass=''
dense
onClick={toggleTracking}
onClick={onToggleSubscribe}
/>
</div>
</div>

View File

@ -1,75 +1,90 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify';
import BackendError from '../../components/BackendError';
import SubmitButton from '../../components/Common/SubmitButton';
import TextInput from '../../components/Common/TextInput';
import { useAuth } from '../../context/AuthContext';
import { IUserUpdatePassword } from '../../utils/models';
export function ChangePassword() {
const { updatePassword, error, loading } = useAuth();
function EditorPassword() {
const { updatePassword, error, setError, loading } = useAuth();
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newPasswordRepeat, setNewPasswordRepeat] = useState('');
const navigate = useNavigate();
const colorClass = useMemo(() => {
return !!newPassword && !!newPasswordRepeat && newPassword !== newPasswordRepeat ? 'bg-red-500' : 'clr-input';
const passwordColor = useMemo(
() => {
return !!newPassword && !!newPasswordRepeat && newPassword !== newPasswordRepeat ? 'clr-input-red' : 'clr-input';
}, [newPassword, newPasswordRepeat]);
const canSubmit = useMemo(
() => {
return !!oldPassword && !!newPassword && !!newPasswordRepeat && newPassword === newPasswordRepeat;
}, [newPassword, newPasswordRepeat, oldPassword]);
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (newPassword !== newPasswordRepeat) {
toast.error('Пароли не совпадают');
return;
}
else {
const data: IUserUpdatePassword = {
old_password: oldPassword,
new_password: newPassword,
};
updatePassword(data, () => {toast.success('Изменения сохранены'); navigate('/login')});
}
updatePassword(data, () => {
toast.success('Изменения сохранены');
navigate('/login')
});
}
useEffect(() => {
setError(undefined);
}, [newPassword, oldPassword, newPasswordRepeat, setError]);
return (
<div className='flex max-w-sm px-4 border-l-2'>
<form onSubmit={handleSubmit} className='flex flex-col justify-between px-6 min-w-fit '>
<div className='flex py-2 border-l-2 clr-border max-w-[14rem]'>
<form onSubmit={handleSubmit} className='flex flex-col justify-between px-6 min-w-fit'>
<div>
<TextInput id='old_password'
type='password'
label='Введите старый пароль:'
label='Старый пароль'
value={oldPassword}
onChange={event => setOldPassword(event.target.value)}
/>
<TextInput id='new_password'
colorClass={colorClass}
label="Введите новый пароль:"
<TextInput id='new_password' type='password'
colorClass={passwordColor}
label="Новый пароль"
value={newPassword}
onChange={event => {
setNewPassword(event.target.value);
}}
/>
<TextInput id='new_password_repeat'
colorClass={colorClass}
label="Повторите новый пароль:"
<TextInput id='new_password_repeat' type='password'
colorClass={passwordColor}
label="Повторите новый"
value={newPasswordRepeat}
onChange={event => {
setNewPasswordRepeat(event.target.value);
}}
/>
</div>
{ error && <BackendError error={error} />}
<div className='flex justify-center py-4'>
<button
type='submit'
className={`px-2 py-1 border clr-btn-blue`}
disabled={loading}>
<span>Сменить пароль</span>
</button>
{ error && <BackendError error={error} />}
<div className='flex justify-center w-full'>
<SubmitButton
disabled={!canSubmit}
loading={loading}
text='Сменить пароль'
/>
</div>
</form>
</div>
)}
)
}
export default EditorPassword;

View File

@ -0,0 +1,68 @@
import { useLayoutEffect, useState } from 'react';
import { toast } from 'react-toastify';
import SubmitButton from '../../components/Common/SubmitButton';
import TextInput from '../../components/Common/TextInput';
import { useUserProfile } from '../../context/UserProfileContext';
import { IUserUpdateData } from '../../utils/models';
function EditorProfile() {
const { updateUser, user, processing } = useUserProfile();
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [first_name, setFirstName] = useState('');
const [last_name, setLastName] = useState('');
useLayoutEffect(() => {
if (user) {
setUsername(user.username);
setEmail(user.email);
setFirstName(user.first_name);
setLastName(user.last_name);
}
}, [user]);
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const data: IUserUpdateData = {
username: username,
email: email,
first_name: first_name,
last_name: last_name,
};
updateUser(data, () => toast.success('Изменения сохранены'));
}
return (
<div className='flex py-2'>
<form onSubmit={handleSubmit} className='px-6 min-w-[18rem]'>
<div>
<TextInput id='username'
label='Логин'
tooltip='Логин изменить нельзя'
disabled={true}
value={username}
onChange={event => setUsername(event.target.value)}
/>
<TextInput id='first_name'
label="Имя"
value={first_name}
onChange={event => setFirstName(event.target.value)}
/>
<TextInput id='last_name' label="Фамилия" value={last_name} onChange={event => setLastName(event.target.value)}/>
<TextInput id='email' label="Электронная почта" value={email} onChange={event => setEmail(event.target.value)}/>
</div>
<div className='flex justify-center w-full mt-10'>
<SubmitButton
text='Сохранить данные'
loading={processing}
/>
</div>
</form>
</div>
)
}
export default EditorProfile;

View File

@ -1,74 +0,0 @@
import { useLayoutEffect, useState } from 'react';
import { toast } from 'react-toastify';
import TextInput from '../../components/Common/TextInput';
import { useUserProfile } from '../../context/UserProfileContext';
import { IUserUpdateData } from '../../utils/models';
import { ChangePassword } from './ChangePassword';
export function UserProfile() {
const { updateUser, user, processing } = useUserProfile();
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [first_name, setFirstName] = useState('');
const [last_name, setLastName] = useState('');
useLayoutEffect(() => {
if (user) {
setUsername(user.username);
setEmail(user.email);
setFirstName(user.first_name);
setLastName(user.last_name);
}
}, [user]);
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const data: IUserUpdateData = {
username: username,
email: email,
first_name: first_name,
last_name: last_name,
};
updateUser(data, () => toast.success('Изменения сохранены'));
}
return (
<div className='flex justify-center'>
<div className='place-self-center'>
<h1 className='flex justify-center py-2'> Учетные данные пользователя </h1>
<div className='flex justify-center px-6 py-2 max-w-fit'>
<div className='flex max-w-sm px-4'>
<form onSubmit={handleSubmit} className='px-6 min-w-fit'>
<div>
<TextInput id='username'
label='Логин:'
value={username}
onChange={event => setUsername(event.target.value)}
/>
<TextInput id='first_name'
label="Имя:"
value={first_name}
onChange={event => setFirstName(event.target.value)}
/>
<TextInput id='last_name' label="Фамилия:" value={last_name} onChange={event => setLastName(event.target.value)}/>
<TextInput id='email' label="Электронная почта:" value={email} onChange={event => setEmail(event.target.value)}/>
</div>
<div className='flex justify-center px-0 py-4'>
<button
type='submit'
className={`px-2 py-1 border clr-btn-green`}
disabled={processing}>
<span>Сохранить мои данные</span>
</button>
</div>
</form>
</div>
<ChangePassword />
</div>
</div>
</div>
)}

View File

@ -1,16 +1,60 @@
import { useMemo, useState } from 'react';
import BackendError from '../../components/BackendError';
import { Loader } from '../../components/Common/Loader';
import MiniButton from '../../components/Common/MiniButton';
import { EyeIcon, EyeOffIcon } from '../../components/Icons';
import { useAuth } from '../../context/AuthContext';
import { useLibrary } from '../../context/LibraryContext';
import { useUserProfile } from '../../context/UserProfileContext';
import { UserProfile } from './UserProfile';
import EditorPassword from './EditorPassword';
import EditorProfile from './EditorProfile';
import ViewSubscriptions from './ViewSubscriptions';
function UserTabs() {
const { user, error, loading } = useUserProfile();
const { user: auth } = useAuth();
const { items } = useLibrary();
const [showSubs, setShowSubs] = useState(true);
const subscriptions = useMemo(
() => {
return items.filter(item => auth?.subscriptions.includes(item.id));
}, [auth, items]);
return (
<div className='w-full'>
{ loading && <Loader /> }
{ error && <BackendError error={error} />}
{ user && <UserProfile /> }
{ user &&
<div className='flex justify-center gap-2 py-2'>
<div className='flex flex-col gap-2 min-w-max'>
<div className='relative w-full'>
<div className='absolute top-0 right-0 mt-2'>
<MiniButton
tooltip='Показать/Скрыть список отслеживаний'
icon={showSubs
? <EyeIcon color='text-primary' size={5}/>
: <EyeOffIcon color='text-primary' size={5}/>
}
onClick={() => setShowSubs(prev => !prev)}
/>
</div>
</div>
<h1>Учетные данные пользователя</h1>
<div className='flex justify-center py-2 max-w-fit'>
<EditorProfile />
<EditorPassword />
</div>
</div>
{subscriptions.length > 0 && showSubs &&
<div className='flex flex-col w-full gap-2 pl-4'>
<h1>Отслеживаемые схемы</h1>
<ViewSubscriptions items={subscriptions} />
</div>}
</div>
}
</div>
);
}

View File

@ -0,0 +1,67 @@
import { useMemo } from 'react';
import { useIntl } from 'react-intl';
import { useNavigate } from 'react-router-dom';
import ConceptDataTable from '../../components/Common/ConceptDataTable';
import { ILibraryItem } from '../../utils/models';
interface ViewSubscriptionsProps {
items: ILibraryItem[]
}
function ViewSubscriptions({items}: ViewSubscriptionsProps) {
const navigate = useNavigate();
const intl = useIntl();
const openRSForm = (item: ILibraryItem) => navigate(`/rsforms/${item.id}`);
const columns = useMemo(() =>
[
{
name: 'Шифр',
id: 'alias',
maxWidth: '140px',
selector: (item: ILibraryItem) => item.alias,
sortable: true,
reorder: true
},
{
name: 'Название',
id: 'title',
minWidth: '50%',
selector: (item: ILibraryItem) => item.title,
sortable: true,
reorder: true
},
{
name: 'Обновлена',
id: 'time_update',
selector: (item: ILibraryItem) => item.time_update,
format: (item: ILibraryItem) => new Date(item.time_update).toLocaleString(intl.locale),
sortable: true,
reorder: true
}
], [intl]);
return (
<ConceptDataTable
className='h-full overflow-auto border clr-border'
columns={columns}
data={items}
defaultSortFieldId='time_update'
defaultSortAsc={false}
noDataComponent={
<div className='h-[10rem]'>Отслеживаемые схемы отсутствуют</div>
}
striped
dense
highlightOnHover
pointerOnHover
onRowClicked={openRSForm}
/>
)
}
export default ViewSubscriptions;

View File

@ -118,7 +118,7 @@ export function getActiveUsers(request: FrontPull<IUserInfo[]>) {
export function getLibrary(request: FrontPull<ILibraryItem[]>) {
AxiosGet({
title: 'Available RSForms (Library) list',
endpoint: '/api/library/active/',
endpoint: '/api/library/active',
request: request
});
}
@ -126,7 +126,7 @@ export function getLibrary(request: FrontPull<ILibraryItem[]>) {
export function postNewRSForm(request: FrontExchange<IRSFormCreateData, ILibraryItem>) {
AxiosPost({
title: 'New RSForm',
endpoint: '/api/rsforms/create-detailed/',
endpoint: '/api/rsforms/create-detailed',
request: request,
options: {
headers: {
@ -139,7 +139,7 @@ export function postNewRSForm(request: FrontExchange<IRSFormCreateData, ILibrary
export function postCloneLibraryItem(target: string, request: FrontExchange<IRSFormCreateData, IRSFormData>) {
AxiosPost({
title: 'clone RSForm',
endpoint: `/api/library/${target}/clone/`,
endpoint: `/api/library/${target}/clone`,
request: request
});
}
@ -147,7 +147,7 @@ export function postCloneLibraryItem(target: string, request: FrontExchange<IRSF
export function getRSFormDetails(target: string, request: FrontPull<IRSFormData>) {
AxiosGet({
title: `RSForm details for id=${target}`,
endpoint: `/api/rsforms/${target}/details/`,
endpoint: `/api/rsforms/${target}/details`,
request: request
});
}
@ -155,7 +155,7 @@ export function getRSFormDetails(target: string, request: FrontPull<IRSFormData>
export function patchLibraryItem(target: string, request: FrontExchange<ILibraryUpdateData, ILibraryItem>) {
AxiosPatch({
title: `RSForm id=${target}`,
endpoint: `/api/library/${target}/`,
endpoint: `/api/library/${target}`,
request: request
});
}
@ -163,7 +163,7 @@ export function patchLibraryItem(target: string, request: FrontExchange<ILibrary
export function deleteLibraryItem(target: string, request: FrontAction) {
AxiosDelete({
title: `RSForm id=${target}`,
endpoint: `/api/library/${target}/`,
endpoint: `/api/library/${target}`,
request: request
});
}
@ -171,7 +171,23 @@ export function deleteLibraryItem(target: string, request: FrontAction) {
export function postClaimLibraryItem(target: string, request: FrontPull<ILibraryItem>) {
AxiosPost({
title: `Claim on RSForm id=${target}`,
endpoint: `/api/library/${target}/claim/`,
endpoint: `/api/library/${target}/claim`,
request: request
});
}
export function postSubscribe(target: string, request: FrontAction) {
AxiosPost({
title: `Claim on RSForm id=${target}`,
endpoint: `/api/library/${target}/subscribe`,
request: request
});
}
export function deleteUnsubscribe(target: string, request: FrontAction) {
AxiosDelete({
title: `Claim on RSForm id=${target}`,
endpoint: `/api/library/${target}/unsubscribe`,
request: request
});
}
@ -179,7 +195,7 @@ export function postClaimLibraryItem(target: string, request: FrontPull<ILibrary
export function getTRSFile(target: string, request: FrontPull<Blob>) {
AxiosGet({
title: `RSForm TRS file for id=${target}`,
endpoint: `/api/rsforms/${target}/export-trs/`,
endpoint: `/api/rsforms/${target}/export-trs`,
request: request,
options: { responseType: 'blob' }
});
@ -188,7 +204,7 @@ export function getTRSFile(target: string, request: FrontPull<Blob>) {
export function postNewConstituenta(schema: string, request: FrontExchange<ICstCreateData, ICstCreatedResponse>) {
AxiosPost({
title: `New Constituenta for RSForm id=${schema}: ${request.data.alias}`,
endpoint: `/api/rsforms/${schema}/cst-create/`,
endpoint: `/api/rsforms/${schema}/cst-create`,
request: request
});
}
@ -196,7 +212,7 @@ export function postNewConstituenta(schema: string, request: FrontExchange<ICstC
export function patchDeleteConstituenta(schema: string, request: FrontExchange<IConstituentaList, IRSFormData>) {
AxiosPatch({
title: `Delete Constituents for RSForm id=${schema}: ${request.data.items.map(item => String(item.id)).join(' ')}`,
endpoint: `/api/rsforms/${schema}/cst-multidelete/`,
endpoint: `/api/rsforms/${schema}/cst-multidelete`,
request: request
});
}
@ -204,7 +220,7 @@ export function patchDeleteConstituenta(schema: string, request: FrontExchange<I
export function patchConstituenta(target: string, request: FrontExchange<ICstUpdateData, IConstituentaMeta>) {
AxiosPatch({
title: `Constituenta id=${target}`,
endpoint: `/api/constituents/${target}/`,
endpoint: `/api/constituents/${target}`,
request: request
});
}
@ -212,7 +228,7 @@ export function patchConstituenta(target: string, request: FrontExchange<ICstUpd
export function patchRenameConstituenta(schema: string, request: FrontExchange<ICstRenameData, ICstCreatedResponse>) {
AxiosPatch({
title: `Renaming constituenta id=${request.data.id} for schema id=${schema}`,
endpoint: `/api/rsforms/${schema}/cst-rename/`,
endpoint: `/api/rsforms/${schema}/cst-rename`,
request: request
});
}
@ -220,7 +236,7 @@ export function patchRenameConstituenta(schema: string, request: FrontExchange<I
export function patchMoveConstituenta(schema: string, request: FrontExchange<ICstMovetoData, IRSFormData>) {
AxiosPatch({
title: `Moving Constituents for RSForm id=${schema}: ${JSON.stringify(request.data.items)} to ${request.data.move_to}`,
endpoint: `/api/rsforms/${schema}/cst-moveto/`,
endpoint: `/api/rsforms/${schema}/cst-moveto`,
request: request
});
}
@ -228,7 +244,7 @@ export function patchMoveConstituenta(schema: string, request: FrontExchange<ICs
export function postCheckExpression(schema: string, request: FrontExchange<IRSExpression, IExpressionParse>) {
AxiosPost({
title: `Check expression for RSForm id=${schema}: ${request.data.expression }`,
endpoint: `/api/rsforms/${schema}/check/`,
endpoint: `/api/rsforms/${schema}/check`,
request: request
});
}
@ -236,7 +252,7 @@ export function postCheckExpression(schema: string, request: FrontExchange<IRSEx
export function postResolveText(schema: string, request: FrontExchange<IRefsText, IReferenceData>) {
AxiosPost({
title: `Resolve text references for RSForm id=${schema}: ${request.data.text }`,
endpoint: `/api/rsforms/${schema}/resolve/`,
endpoint: `/api/rsforms/${schema}/resolve`,
request: request
});
}
@ -244,7 +260,7 @@ export function postResolveText(schema: string, request: FrontExchange<IRefsText
export function patchResetAliases(target: string, request: FrontPull<IRSFormData>) {
AxiosPatch({
title: `Reset alias for RSForm id=${target}`,
endpoint: `/api/rsforms/${target}/reset-aliases/`,
endpoint: `/api/rsforms/${target}/reset-aliases`,
request: request
});
}
@ -252,7 +268,7 @@ export function patchResetAliases(target: string, request: FrontPull<IRSFormData
export function patchUploadTRS(target: string, request: FrontExchange<IRSFormUploadData, IRSFormData>) {
AxiosPatch({
title: `Replacing data with trs file for RSForm id=${target}`,
endpoint: `/api/rsforms/${target}/load-trs/`,
endpoint: `/api/rsforms/${target}/load-trs`,
request: request,
options: {
headers: {

View File

@ -34,4 +34,5 @@ export const prefixes = {
cst_list: 'cst-list-',
cst_status_list: 'cst-status-list-',
topic_list: 'topic-list-',
library_list: 'library-list-'
}

View File

@ -11,7 +11,9 @@ export interface IUser {
last_name: string
}
export interface ICurrentUser extends Pick<IUser, 'id' | 'username' | 'is_staff'> {}
export interface ICurrentUser extends Pick<IUser, 'id' | 'username' | 'is_staff'> {
subscriptions: number[]
}
export interface IUserLoginData extends Pick<IUser, 'username'> {
password: string
@ -261,6 +263,7 @@ extends ILibraryItem {
items: IConstituenta[]
stats: IRSFormStats
graph: Graph
subscribers: number[]
}
export interface IRSFormData extends Omit<IRSForm, 'stats' | 'graph'> {}