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. ''' ''' Admin model: Constituenta. '''
class Librarydmin(admin.ModelAdmin): class LibraryAdmin(admin.ModelAdmin):
''' Admin model: LibraryItem. ''' ''' Admin model: LibraryItem. '''
class SubscriptionAdmin(admin.ModelAdmin):
''' Admin model: Subscriptions. '''
admin.site.register(models.Constituenta, ConstituentaAdmin) 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 import apps.rsform.models
from django.conf import settings from django.conf import settings
@ -35,18 +35,6 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'Схемы', '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( migrations.CreateModel(
name='Constituenta', name='Constituenta',
fields=[ fields=[
@ -68,4 +56,17 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'Конституенты', '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): def get_absolute_url(self):
return f'/api/library/{self.pk}/' 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): class Subscription(Model):
''' User subscription to library item. ''' ''' User subscription to library item. '''
@ -145,10 +156,28 @@ class Subscription(Model):
''' Model metadata. ''' ''' Model metadata. '''
verbose_name = 'Подписки' verbose_name = 'Подписки'
verbose_name_plural = 'Подписка' verbose_name_plural = 'Подписка'
unique_together = [['user', 'item']]
def __str__(self) -> str: def __str__(self) -> str:
return f'{self.user} -> {self.item}' 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): class Constituenta(Model):
''' Constituenta is the base unit for every conceptual schema ''' ''' 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_update'] = self.schema.item.time_update
result['time_create'] = self.schema.item.time_create result['time_create'] = self.schema.item.time_create
result['is_common'] = self.schema.item.is_common 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) result['owner'] = (self.schema.item.owner.pk if self.schema.item.owner is not None else None)
for cst_data in result['items']: for cst_data in result['items']:
cst = Constituenta.objects.get(pk=cst_data['id']) cst = Constituenta.objects.get(pk=cst_data['id'])
@ -527,6 +557,7 @@ class PyConceptAdapter:
'raw': cst.definition_raw, 'raw': cst.definition_raw,
'resolved': cst.definition_resolved, 'resolved': cst.definition_resolved,
} }
result['subscribers'] = [item.pk for item in self.schema.item.subscribers()]
return result return result
def _prepare_request(self) -> dict: def _prepare_request(self) -> dict:

View File

@ -5,12 +5,9 @@ from django.db.utils import IntegrityError
from django.forms import ValidationError from django.forms import ValidationError
from apps.rsform.models import ( from apps.rsform.models import (
RSForm, RSForm, Constituenta, CstType,
Constituenta,
CstType,
User, User,
LibraryItem, LibraryItem, LibraryItemType, Subscription
LibraryItemType
) )
@ -28,7 +25,7 @@ class TestConstituenta(TestCase):
def test_url(self): def test_url(self):
testStr = 'X1' testStr = 'X1'
cst = Constituenta.objects.create(alias=testStr, schema=self.schema1, order=1, convention='Test') 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): def test_order_not_null(self):
with self.assertRaises(IntegrityError): with self.assertRaises(IntegrityError):
@ -110,7 +107,39 @@ class TestLibraryItem(TestCase):
self.assertEqual(item.comment, 'Test comment') self.assertEqual(item.comment, 'Test comment')
self.assertEqual(item.is_common, True) self.assertEqual(item.is_common, True)
self.assertEqual(item.is_canonical, 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): class TestRSForm(TestCase):
''' Testing RSForm wrapper. ''' ''' Testing RSForm wrapper. '''

View File

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

View File

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

View File

@ -24,7 +24,8 @@ class LibraryActiveView(generics.ListAPIView):
def get_queryset(self): def get_queryset(self):
user = self.request.user user = self.request.user
if not user.is_anonymous: 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: else:
return m.LibraryItem.objects.filter(is_common=True) return m.LibraryItem.objects.filter(is_common=True)
@ -62,7 +63,7 @@ class LibraryViewSet(viewsets.ModelViewSet):
def get_permissions(self): def get_permissions(self):
if self.action in ['update', 'destroy', 'partial_update']: if self.action in ['update', 'destroy', 'partial_update']:
permission_classes = [utils.ObjectOwnerOrAdmin] permission_classes = [utils.ObjectOwnerOrAdmin]
elif self.action in ['create', 'clone']: elif self.action in ['create', 'clone', 'subscribe', 'unsubscribe']:
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
elif self.action in ['claim']: elif self.action in ['claim']:
permission_classes = [utils.IsClaimable] permission_classes = [utils.IsClaimable]
@ -70,13 +71,16 @@ class LibraryViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.AllowAny] permission_classes = [permissions.AllowAny]
return [permission() for permission in permission_classes] return [permission() for permission in permission_classes]
def _get_item(self) -> m.LibraryItem:
return cast(m.LibraryItem, self.get_object())
@transaction.atomic @transaction.atomic
@action(detail=True, methods=['post'], url_path='clone') @action(detail=True, methods=['post'], url_path='clone')
def clone(self, request, pk): def clone(self, request, pk):
''' Endpoint: Create deep copy of library item. ''' ''' Endpoint: Create deep copy of library item. '''
serializer = s.LibraryItemSerializer(data=request.data) serializer = s.LibraryItemSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
item = cast(m.LibraryItem, self.get_object()) item = self._get_item()
if item.item_type == m.LibraryItemType.RSFORM: if item.item_type == m.LibraryItemType.RSFORM:
schema = m.RSForm(item) schema = m.RSForm(item)
clone_data = s.RSFormTRSSerializer(schema).data 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=201, data=m.PyConceptAdapter(new_schema).full())
return Response(status=404) return Response(status=404)
@transaction.atomic
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
def claim(self, request, pk=None): def claim(self, request, pk=None):
''' Endpoint: Claim ownership of LibraryItem. ''' ''' Endpoint: Claim ownership of LibraryItem. '''
item = cast(m.LibraryItem, self.get_object()) item = self._get_item()
if item.owner == self.request.user: if item.owner == self.request.user:
return Response(status=304) return Response(status=304)
else: else:
item.owner = self.request.user item.owner = self.request.user
item.save() item.save()
m.Subscription.subscribe(user=item.owner, item=item)
return Response(status=200, data=s.LibraryItemSerializer(item).data) 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): class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.RetrieveAPIView):
''' Endpoint: RSForm operations. ''' ''' 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 serializer_class = s.LibraryItemSerializer
def _get_schema(self) -> m.RSForm: 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 django.contrib.auth.password_validation import validate_password
from rest_framework import serializers from rest_framework import serializers
from apps.rsform.models import Subscription
from . import models from . import models
@ -40,16 +41,23 @@ class LoginSerializer(serializers.Serializer):
raise NotImplementedError('unexpected `update()` call') raise NotImplementedError('unexpected `update()` call')
class AuthSerializer(serializers.ModelSerializer): class AuthSerializer(serializers.Serializer):
''' Serializer: Authentication data. ''' ''' Serializer: Authentication data. '''
class Meta: def to_representation(self, instance: models.User) -> dict:
''' serializer metadata. ''' if instance.is_anonymous:
model = models.User return {
fields = [ 'id': None,
'id', 'username': '',
'username', 'is_staff': False,
'is_staff' '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): class UserInfoSerializer(serializers.ModelSerializer):

View File

@ -3,9 +3,10 @@ import json
from rest_framework.test import APITestCase, APIClient from rest_framework.test import APITestCase, APIClient
from apps.users.models import User 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): class TestUserAPIViews(APITestCase):
def setUp(self): def setUp(self):
self.username = 'UserTest' self.username = 'UserTest'
@ -30,6 +31,30 @@ class TestUserAPIViews(APITestCase):
self.assertEqual(self.client.post('/users/api/logout').status_code, 403) 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): class TestUserUserProfileAPIView(APITestCase):
def setUp(self): def setUp(self):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -110,6 +110,10 @@
@apply bg-white dark:bg-gray-900 checked:bg-blue-700 dark:checked:bg-orange-500 @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 { .text-url {
@apply hover:text-blue-600 text-blue-400 dark:text-orange-600 dark:hover:text-orange-400 @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 ConceptDataTable from '../../components/Common/ConceptDataTable';
import TextURL from '../../components/Common/TextURL'; import TextURL from '../../components/Common/TextURL';
import { EducationIcon, EyeIcon, GroupIcon } from '../../components/Icons';
import { useAuth } from '../../context/AuthContext';
import { useNavSearch } from '../../context/NavSearchContext'; import { useNavSearch } from '../../context/NavSearchContext';
import { useUsers } from '../../context/UsersContext'; import { useUsers } from '../../context/UsersContext';
import { prefixes } from '../../utils/constants';
import { ILibraryItem } from '../../utils/models' import { ILibraryItem } from '../../utils/models'
interface ViewLibraryProps { interface ViewLibraryProps {
@ -14,14 +17,35 @@ interface ViewLibraryProps {
function ViewLibrary({ items }: ViewLibraryProps) { function ViewLibrary({ items }: ViewLibraryProps) {
const { resetQuery: cleanQuery } = useNavSearch(); const { resetQuery: cleanQuery } = useNavSearch();
const { user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const intl = useIntl(); const intl = useIntl();
const { getUserLabel } = useUsers(); const { getUserLabel } = useUsers();
const openRSForm = (item: ILibraryItem) => navigate(`/rsforms/${item.id}`); 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: 'Шифр', name: 'Шифр',
id: 'alias', id: 'alias',
@ -30,16 +54,6 @@ function ViewLibrary({ items }: ViewLibraryProps) {
sortable: true, sortable: true,
reorder: 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: 'Название', name: 'Название',
id: 'title', id: 'title',
@ -66,7 +80,7 @@ function ViewLibrary({ items }: ViewLibraryProps) {
sortable: true, sortable: true,
reorder: true reorder: true
} }
], [intl, getUserLabel]); ], [intl, getUserLabel, user]);
return ( return (
<ConceptDataTable <ConceptDataTable

View File

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

View File

@ -161,6 +161,12 @@ function EditorRSForm({ onDestroy, onClaim, onShare, onDownload }: EditorRSFormP
{getUserLabel(schema?.owner ?? null)} {getUserLabel(schema?.owner ?? null)}
</span> </span>
</div> </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'> <div className='flex justify-start mt-2'>
<label className='font-semibold'>Дата обновления:</label> <label className='font-semibold'>Дата обновления:</label>
<span className='ml-2'>{schema && new Date(schema?.time_update).toLocaleString(intl.locale)}</span> <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; return result;
}, [schema, coloringScheme, filtered.nodes, darkMode, noTerms]); }, [schema, coloringScheme, filtered.nodes, darkMode, noTerms]);
@ -194,7 +193,6 @@ function EditorTermGraph({ onOpenEdit, onCreateCst, onDeleteCst }: EditorTermGra
edgeID += 1; edgeID += 1;
}); });
}); });
console.log(result);
return result; return result;
}, [filtered.nodes]); }, [filtered.nodes]);
@ -256,7 +254,6 @@ function EditorTermGraph({ onOpenEdit, onCreateCst, onDeleteCst }: EditorTermGra
// Implement hotkeys for editing // Implement hotkeys for editing
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) { function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
console.log(event);
if (!isEditable) { if (!isEditable) {
return; return;
} }

View File

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

View File

@ -16,17 +16,18 @@ interface RSTabsMenuProps {
onClaim: () => void onClaim: () => void
onShare: () => void onShare: () => void
onDownload: () => void onDownload: () => void
onToggleSubscribe: () => void
} }
function RSTabsMenu({ function RSTabsMenu({
showUploadDialog, showCloneDialog, showUploadDialog, showCloneDialog,
onDestroy, onShare, onDownload, onClaim onDestroy, onShare, onDownload, onClaim, onToggleSubscribe
}: RSTabsMenuProps) { }: RSTabsMenuProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
const { const {
isOwned, isEditable, isTracking, isReadonly: readonly, isForceAdmin: forceAdmin, isOwned, isEditable, isTracking, isReadonly, isClaimable, isForceAdmin,
toggleTracking, toggleForceAdmin, toggleReadonly toggleForceAdmin, toggleReadonly, processing
} = useRSForm(); } = useRSForm();
const schemaMenu = useDropdown(); const schemaMenu = useDropdown();
const editMenu = useDropdown(); const editMenu = useDropdown();
@ -127,7 +128,11 @@ function RSTabsMenu({
/> />
{ editMenu.isActive && { editMenu.isActive &&
<Dropdown> <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'> <div className='inline-flex items-center gap-1 justify-normal'>
<span className={isOwned ? 'text-green' : ''}><CrownIcon size={4} /></span> <span className={isOwned ? 'text-green' : ''}><CrownIcon size={4} /></span>
<p> <p>
@ -139,20 +144,21 @@ function RSTabsMenu({
{(isOwned || user?.is_staff) && {(isOwned || user?.is_staff) &&
<DropdownButton onClick={toggleReadonly}> <DropdownButton onClick={toggleReadonly}>
<Checkbox <Checkbox
value={readonly} value={isReadonly}
label='Я — читатель!' label='Я — читатель!'
tooltip='Режим чтения' tooltip='Режим чтения'
/> />
</DropdownButton>} </DropdownButton>}
{user?.is_staff && {user?.is_staff &&
<DropdownButton onClick={toggleForceAdmin}> <DropdownButton onClick={toggleForceAdmin}>
<Checkbox value={forceAdmin} label='режим администратора'/> <Checkbox value={isForceAdmin} label='режим администратора'/>
</DropdownButton>} </DropdownButton>}
</Dropdown>} </Dropdown>}
</div> </div>
<div> <div>
<Button <Button
tooltip={'отслеживание: ' + (isTracking ? '[включено]' : '[выключено]')} tooltip={'отслеживание: ' + (isTracking ? '[включено]' : '[выключено]')}
disabled={processing}
icon={isTracking icon={isTracking
? <EyeIcon color='text-primary' size={5}/> ? <EyeIcon color='text-primary' size={5}/>
: <EyeOffIcon size={5}/> : <EyeOffIcon size={5}/>
@ -160,7 +166,7 @@ function RSTabsMenu({
widthClass='h-full w-fit' widthClass='h-full w-fit'
borderClass='' borderClass=''
dense dense
onClick={toggleTracking} onClick={onToggleSubscribe}
/> />
</div> </div>
</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 { useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import BackendError from '../../components/BackendError'; import BackendError from '../../components/BackendError';
import SubmitButton from '../../components/Common/SubmitButton';
import TextInput from '../../components/Common/TextInput'; import TextInput from '../../components/Common/TextInput';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import { IUserUpdatePassword } from '../../utils/models'; import { IUserUpdatePassword } from '../../utils/models';
export function ChangePassword() { function EditorPassword() {
const { updatePassword, error, loading } = useAuth(); const { updatePassword, error, setError, loading } = useAuth();
const [oldPassword, setOldPassword] = useState(''); const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [newPasswordRepeat, setNewPasswordRepeat] = useState(''); const [newPasswordRepeat, setNewPasswordRepeat] = useState('');
const navigate = useNavigate(); const navigate = useNavigate();
const colorClass = useMemo(() => { const passwordColor = useMemo(
return !!newPassword && !!newPasswordRepeat && newPassword !== newPasswordRepeat ? 'bg-red-500' : 'clr-input'; () => {
return !!newPassword && !!newPasswordRepeat && newPassword !== newPasswordRepeat ? 'clr-input-red' : 'clr-input';
}, [newPassword, newPasswordRepeat]); }, [newPassword, newPasswordRepeat]);
const canSubmit = useMemo(
() => {
return !!oldPassword && !!newPassword && !!newPasswordRepeat && newPassword === newPasswordRepeat;
}, [newPassword, newPasswordRepeat, oldPassword]);
function handleSubmit(event: React.FormEvent<HTMLFormElement>) { function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (newPassword !== newPasswordRepeat) { if (newPassword !== newPasswordRepeat) {
toast.error('Пароли не совпадают'); toast.error('Пароли не совпадают');
return;
} }
else {
const data: IUserUpdatePassword = { const data: IUserUpdatePassword = {
old_password: oldPassword, old_password: oldPassword,
new_password: newPassword, new_password: newPassword,
}; };
updatePassword(data, () => {toast.success('Изменения сохранены'); navigate('/login')}); updatePassword(data, () => {
} toast.success('Изменения сохранены');
navigate('/login')
});
} }
useEffect(() => {
setError(undefined);
}, [newPassword, oldPassword, newPasswordRepeat, setError]);
return ( return (
<div className='flex max-w-sm px-4 border-l-2'> <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 '> <form onSubmit={handleSubmit} className='flex flex-col justify-between px-6 min-w-fit'>
<div> <div>
<TextInput id='old_password' <TextInput id='old_password'
type='password' type='password'
label='Введите старый пароль:' label='Старый пароль'
value={oldPassword} value={oldPassword}
onChange={event => setOldPassword(event.target.value)} onChange={event => setOldPassword(event.target.value)}
/> />
<TextInput id='new_password' <TextInput id='new_password' type='password'
colorClass={colorClass} colorClass={passwordColor}
label="Введите новый пароль:" label="Новый пароль"
value={newPassword} value={newPassword}
onChange={event => { onChange={event => {
setNewPassword(event.target.value); setNewPassword(event.target.value);
}} }}
/> />
<TextInput id='new_password_repeat' <TextInput id='new_password_repeat' type='password'
colorClass={colorClass} colorClass={passwordColor}
label="Повторите новый пароль:" label="Повторите новый"
value={newPasswordRepeat} value={newPasswordRepeat}
onChange={event => { onChange={event => {
setNewPasswordRepeat(event.target.value); setNewPasswordRepeat(event.target.value);
}} }}
/> />
</div> </div>
{ error && <BackendError error={error} />} { error && <BackendError error={error} />}
<div className='flex justify-center py-4'> <div className='flex justify-center w-full'>
<button <SubmitButton
type='submit' disabled={!canSubmit}
className={`px-2 py-1 border clr-btn-blue`} loading={loading}
disabled={loading}> text='Сменить пароль'
<span>Сменить пароль</span> />
</button>
</div> </div>
</form> </form>
</div> </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 BackendError from '../../components/BackendError';
import { Loader } from '../../components/Common/Loader'; 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 { useUserProfile } from '../../context/UserProfileContext';
import { UserProfile } from './UserProfile'; import EditorPassword from './EditorPassword';
import EditorProfile from './EditorProfile';
import ViewSubscriptions from './ViewSubscriptions';
function UserTabs() { function UserTabs() {
const { user, error, loading } = useUserProfile(); 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 ( return (
<div className='w-full'> <div className='w-full'>
{ loading && <Loader /> } { loading && <Loader /> }
{ error && <BackendError error={error} />} { 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> </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[]>) { export function getLibrary(request: FrontPull<ILibraryItem[]>) {
AxiosGet({ AxiosGet({
title: 'Available RSForms (Library) list', title: 'Available RSForms (Library) list',
endpoint: '/api/library/active/', endpoint: '/api/library/active',
request: request request: request
}); });
} }
@ -126,7 +126,7 @@ export function getLibrary(request: FrontPull<ILibraryItem[]>) {
export function postNewRSForm(request: FrontExchange<IRSFormCreateData, ILibraryItem>) { export function postNewRSForm(request: FrontExchange<IRSFormCreateData, ILibraryItem>) {
AxiosPost({ AxiosPost({
title: 'New RSForm', title: 'New RSForm',
endpoint: '/api/rsforms/create-detailed/', endpoint: '/api/rsforms/create-detailed',
request: request, request: request,
options: { options: {
headers: { headers: {
@ -139,7 +139,7 @@ export function postNewRSForm(request: FrontExchange<IRSFormCreateData, ILibrary
export function postCloneLibraryItem(target: string, request: FrontExchange<IRSFormCreateData, IRSFormData>) { export function postCloneLibraryItem(target: string, request: FrontExchange<IRSFormCreateData, IRSFormData>) {
AxiosPost({ AxiosPost({
title: 'clone RSForm', title: 'clone RSForm',
endpoint: `/api/library/${target}/clone/`, endpoint: `/api/library/${target}/clone`,
request: request request: request
}); });
} }
@ -147,7 +147,7 @@ export function postCloneLibraryItem(target: string, request: FrontExchange<IRSF
export function getRSFormDetails(target: string, request: FrontPull<IRSFormData>) { export function getRSFormDetails(target: string, request: FrontPull<IRSFormData>) {
AxiosGet({ AxiosGet({
title: `RSForm details for id=${target}`, title: `RSForm details for id=${target}`,
endpoint: `/api/rsforms/${target}/details/`, endpoint: `/api/rsforms/${target}/details`,
request: request request: request
}); });
} }
@ -155,7 +155,7 @@ export function getRSFormDetails(target: string, request: FrontPull<IRSFormData>
export function patchLibraryItem(target: string, request: FrontExchange<ILibraryUpdateData, ILibraryItem>) { export function patchLibraryItem(target: string, request: FrontExchange<ILibraryUpdateData, ILibraryItem>) {
AxiosPatch({ AxiosPatch({
title: `RSForm id=${target}`, title: `RSForm id=${target}`,
endpoint: `/api/library/${target}/`, endpoint: `/api/library/${target}`,
request: request request: request
}); });
} }
@ -163,7 +163,7 @@ export function patchLibraryItem(target: string, request: FrontExchange<ILibrary
export function deleteLibraryItem(target: string, request: FrontAction) { export function deleteLibraryItem(target: string, request: FrontAction) {
AxiosDelete({ AxiosDelete({
title: `RSForm id=${target}`, title: `RSForm id=${target}`,
endpoint: `/api/library/${target}/`, endpoint: `/api/library/${target}`,
request: request request: request
}); });
} }
@ -171,7 +171,23 @@ export function deleteLibraryItem(target: string, request: FrontAction) {
export function postClaimLibraryItem(target: string, request: FrontPull<ILibraryItem>) { export function postClaimLibraryItem(target: string, request: FrontPull<ILibraryItem>) {
AxiosPost({ AxiosPost({
title: `Claim on RSForm id=${target}`, 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 request: request
}); });
} }
@ -179,7 +195,7 @@ export function postClaimLibraryItem(target: string, request: FrontPull<ILibrary
export function getTRSFile(target: string, request: FrontPull<Blob>) { export function getTRSFile(target: string, request: FrontPull<Blob>) {
AxiosGet({ AxiosGet({
title: `RSForm TRS file for id=${target}`, title: `RSForm TRS file for id=${target}`,
endpoint: `/api/rsforms/${target}/export-trs/`, endpoint: `/api/rsforms/${target}/export-trs`,
request: request, request: request,
options: { responseType: 'blob' } options: { responseType: 'blob' }
}); });
@ -188,7 +204,7 @@ export function getTRSFile(target: string, request: FrontPull<Blob>) {
export function postNewConstituenta(schema: string, request: FrontExchange<ICstCreateData, ICstCreatedResponse>) { export function postNewConstituenta(schema: string, request: FrontExchange<ICstCreateData, ICstCreatedResponse>) {
AxiosPost({ AxiosPost({
title: `New Constituenta for RSForm id=${schema}: ${request.data.alias}`, title: `New Constituenta for RSForm id=${schema}: ${request.data.alias}`,
endpoint: `/api/rsforms/${schema}/cst-create/`, endpoint: `/api/rsforms/${schema}/cst-create`,
request: request request: request
}); });
} }
@ -196,7 +212,7 @@ export function postNewConstituenta(schema: string, request: FrontExchange<ICstC
export function patchDeleteConstituenta(schema: string, request: FrontExchange<IConstituentaList, IRSFormData>) { export function patchDeleteConstituenta(schema: string, request: FrontExchange<IConstituentaList, IRSFormData>) {
AxiosPatch({ AxiosPatch({
title: `Delete Constituents for RSForm id=${schema}: ${request.data.items.map(item => String(item.id)).join(' ')}`, 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 request: request
}); });
} }
@ -204,7 +220,7 @@ export function patchDeleteConstituenta(schema: string, request: FrontExchange<I
export function patchConstituenta(target: string, request: FrontExchange<ICstUpdateData, IConstituentaMeta>) { export function patchConstituenta(target: string, request: FrontExchange<ICstUpdateData, IConstituentaMeta>) {
AxiosPatch({ AxiosPatch({
title: `Constituenta id=${target}`, title: `Constituenta id=${target}`,
endpoint: `/api/constituents/${target}/`, endpoint: `/api/constituents/${target}`,
request: request request: request
}); });
} }
@ -212,7 +228,7 @@ export function patchConstituenta(target: string, request: FrontExchange<ICstUpd
export function patchRenameConstituenta(schema: string, request: FrontExchange<ICstRenameData, ICstCreatedResponse>) { export function patchRenameConstituenta(schema: string, request: FrontExchange<ICstRenameData, ICstCreatedResponse>) {
AxiosPatch({ AxiosPatch({
title: `Renaming constituenta id=${request.data.id} for schema id=${schema}`, 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 request: request
}); });
} }
@ -220,7 +236,7 @@ export function patchRenameConstituenta(schema: string, request: FrontExchange<I
export function patchMoveConstituenta(schema: string, request: FrontExchange<ICstMovetoData, IRSFormData>) { export function patchMoveConstituenta(schema: string, request: FrontExchange<ICstMovetoData, IRSFormData>) {
AxiosPatch({ AxiosPatch({
title: `Moving Constituents for RSForm id=${schema}: ${JSON.stringify(request.data.items)} to ${request.data.move_to}`, 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 request: request
}); });
} }
@ -228,7 +244,7 @@ export function patchMoveConstituenta(schema: string, request: FrontExchange<ICs
export function postCheckExpression(schema: string, request: FrontExchange<IRSExpression, IExpressionParse>) { export function postCheckExpression(schema: string, request: FrontExchange<IRSExpression, IExpressionParse>) {
AxiosPost({ AxiosPost({
title: `Check expression for RSForm id=${schema}: ${request.data.expression }`, title: `Check expression for RSForm id=${schema}: ${request.data.expression }`,
endpoint: `/api/rsforms/${schema}/check/`, endpoint: `/api/rsforms/${schema}/check`,
request: request request: request
}); });
} }
@ -236,7 +252,7 @@ export function postCheckExpression(schema: string, request: FrontExchange<IRSEx
export function postResolveText(schema: string, request: FrontExchange<IRefsText, IReferenceData>) { export function postResolveText(schema: string, request: FrontExchange<IRefsText, IReferenceData>) {
AxiosPost({ AxiosPost({
title: `Resolve text references for RSForm id=${schema}: ${request.data.text }`, title: `Resolve text references for RSForm id=${schema}: ${request.data.text }`,
endpoint: `/api/rsforms/${schema}/resolve/`, endpoint: `/api/rsforms/${schema}/resolve`,
request: request request: request
}); });
} }
@ -244,7 +260,7 @@ export function postResolveText(schema: string, request: FrontExchange<IRefsText
export function patchResetAliases(target: string, request: FrontPull<IRSFormData>) { export function patchResetAliases(target: string, request: FrontPull<IRSFormData>) {
AxiosPatch({ AxiosPatch({
title: `Reset alias for RSForm id=${target}`, title: `Reset alias for RSForm id=${target}`,
endpoint: `/api/rsforms/${target}/reset-aliases/`, endpoint: `/api/rsforms/${target}/reset-aliases`,
request: request request: request
}); });
} }
@ -252,7 +268,7 @@ export function patchResetAliases(target: string, request: FrontPull<IRSFormData
export function patchUploadTRS(target: string, request: FrontExchange<IRSFormUploadData, IRSFormData>) { export function patchUploadTRS(target: string, request: FrontExchange<IRSFormUploadData, IRSFormData>) {
AxiosPatch({ AxiosPatch({
title: `Replacing data with trs file for RSForm id=${target}`, title: `Replacing data with trs file for RSForm id=${target}`,
endpoint: `/api/rsforms/${target}/load-trs/`, endpoint: `/api/rsforms/${target}/load-trs`,
request: request, request: request,
options: { options: {
headers: { headers: {

View File

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

View File

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