diff --git a/rsconcept/backend/apps/rsform/admin.py b/rsconcept/backend/apps/rsform/admin.py index b4d370d6..2d95688e 100644 --- a/rsconcept/backend/apps/rsform/admin.py +++ b/rsconcept/backend/apps/rsform/admin.py @@ -8,9 +8,14 @@ class ConstituentaAdmin(admin.ModelAdmin): ''' Admin model: Constituenta. ''' -class Librarydmin(admin.ModelAdmin): +class LibraryAdmin(admin.ModelAdmin): ''' Admin model: LibraryItem. ''' +class SubscriptionAdmin(admin.ModelAdmin): + ''' Admin model: Subscriptions. ''' + + admin.site.register(models.Constituenta, ConstituentaAdmin) -admin.site.register(models.LibraryItem, Librarydmin) +admin.site.register(models.LibraryItem, LibraryAdmin) +admin.site.register(models.Subscription, SubscriptionAdmin) diff --git a/rsconcept/backend/apps/rsform/migrations/0001_initial.py b/rsconcept/backend/apps/rsform/migrations/0001_initial.py index 3ab27367..1aff9c2f 100644 --- a/rsconcept/backend/apps/rsform/migrations/0001_initial.py +++ b/rsconcept/backend/apps/rsform/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.4 on 2023-08-25 12:15 +# Generated by Django 4.2.4 on 2023-08-26 10:09 import apps.rsform.models from django.conf import settings @@ -35,18 +35,6 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Схемы', }, ), - migrations.CreateModel( - name='Subscription', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='rsform.libraryitem', verbose_name='Элемент')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')), - ], - options={ - 'verbose_name': 'Подписки', - 'verbose_name_plural': 'Подписка', - }, - ), migrations.CreateModel( name='Constituenta', fields=[ @@ -68,4 +56,17 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Конституенты', }, ), + migrations.CreateModel( + name='Subscription', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='rsform.libraryitem', verbose_name='Элемент')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')), + ], + options={ + 'verbose_name': 'Подписки', + 'verbose_name_plural': 'Подписка', + 'unique_together': {('user', 'item')}, + }, + ), ] diff --git a/rsconcept/backend/apps/rsform/models.py b/rsconcept/backend/apps/rsform/models.py index 6fac23de..c631a04c 100644 --- a/rsconcept/backend/apps/rsform/models.py +++ b/rsconcept/backend/apps/rsform/models.py @@ -127,6 +127,17 @@ class LibraryItem(Model): def get_absolute_url(self): return f'/api/library/{self.pk}/' + def subscribers(self) -> list[User]: + ''' Get all subscribers for this item . ''' + return [s.user for s in Subscription.objects.filter(item=self.pk)] + + @transaction.atomic + def save(self, *args, **kwargs): + subscribe = not self.pk and self.owner + super().save(*args, **kwargs) + if subscribe: + Subscription.subscribe(user=self.owner, item=self) + class Subscription(Model): ''' User subscription to library item. ''' @@ -145,10 +156,28 @@ class Subscription(Model): ''' Model metadata. ''' verbose_name = 'Подписки' verbose_name_plural = 'Подписка' + unique_together = [['user', 'item']] def __str__(self) -> str: return f'{self.user} -> {self.item}' + @staticmethod + def subscribe(user: User, item: LibraryItem) -> bool: + ''' Add subscription. ''' + if Subscription.objects.filter(user=user, item=item).exists(): + return False + Subscription.objects.create(user=user, item=item) + return True + + @staticmethod + def unsubscribe(user: User, item: LibraryItem) -> bool: + ''' Remove subscription. ''' + sub = Subscription.objects.filter(user=user, item=item) + if not sub.exists(): + return False + sub.delete() + return True + class Constituenta(Model): ''' Constituenta is the base unit for every conceptual schema ''' @@ -514,6 +543,7 @@ class PyConceptAdapter: result['time_update'] = self.schema.item.time_update result['time_create'] = self.schema.item.time_create result['is_common'] = self.schema.item.is_common + result['is_canonical'] = self.schema.item.is_canonical result['owner'] = (self.schema.item.owner.pk if self.schema.item.owner is not None else None) for cst_data in result['items']: cst = Constituenta.objects.get(pk=cst_data['id']) @@ -527,6 +557,7 @@ class PyConceptAdapter: 'raw': cst.definition_raw, 'resolved': cst.definition_resolved, } + result['subscribers'] = [item.pk for item in self.schema.item.subscribers()] return result def _prepare_request(self) -> dict: diff --git a/rsconcept/backend/apps/rsform/tests/data/sample-rsform.trs b/rsconcept/backend/apps/rsform/tests/data/sample-rsform.trs index 3d1c3a47..fb49cdd8 100644 Binary files a/rsconcept/backend/apps/rsform/tests/data/sample-rsform.trs and b/rsconcept/backend/apps/rsform/tests/data/sample-rsform.trs differ diff --git a/rsconcept/backend/apps/rsform/tests/t_models.py b/rsconcept/backend/apps/rsform/tests/t_models.py index adb2f612..41970602 100644 --- a/rsconcept/backend/apps/rsform/tests/t_models.py +++ b/rsconcept/backend/apps/rsform/tests/t_models.py @@ -5,12 +5,9 @@ from django.db.utils import IntegrityError from django.forms import ValidationError from apps.rsform.models import ( - RSForm, - Constituenta, - CstType, + RSForm, Constituenta, CstType, User, - LibraryItem, - LibraryItemType + LibraryItem, LibraryItemType, Subscription ) @@ -28,7 +25,7 @@ class TestConstituenta(TestCase): def test_url(self): testStr = 'X1' cst = Constituenta.objects.create(alias=testStr, schema=self.schema1, order=1, convention='Test') - self.assertEqual(cst.get_absolute_url(), f'/api/constituents/{cst.id}/') + self.assertEqual(cst.get_absolute_url(), f'/api/constituents/{cst.id}') def test_order_not_null(self): with self.assertRaises(IntegrityError): @@ -110,7 +107,39 @@ class TestLibraryItem(TestCase): self.assertEqual(item.comment, 'Test comment') self.assertEqual(item.is_common, True) self.assertEqual(item.is_canonical, True) + self.assertTrue(Subscription.objects.filter(user=item.owner, item=item).exists()) + def test_subscribe(self): + item = LibraryItem.objects.create(item_type=LibraryItemType.RSFORM, title='Test') + self.assertEqual(len(item.subscribers()), 0) + + self.assertTrue(Subscription.subscribe(self.user1, item)) + self.assertEqual(len(item.subscribers()), 1) + self.assertTrue(self.user1 in item.subscribers()) + + self.assertFalse(Subscription.subscribe(self.user1, item)) + self.assertEqual(len(item.subscribers()), 1) + + self.assertTrue(Subscription.subscribe(self.user2, item)) + self.assertEqual(len(item.subscribers()), 2) + self.assertTrue(self.user1 in item.subscribers()) + self.assertTrue(self.user2 in item.subscribers()) + + self.user1.delete() + self.assertEqual(len(item.subscribers()), 1) + + def test_unsubscribe(self): + item = LibraryItem.objects.create(item_type=LibraryItemType.RSFORM, title='Test') + self.assertFalse(Subscription.unsubscribe(self.user1, item)) + Subscription.subscribe(self.user1, item) + Subscription.subscribe(self.user2, item) + self.assertEqual(len(item.subscribers()), 2) + + self.assertTrue(Subscription.unsubscribe(self.user1, item)) + self.assertEqual(len(item.subscribers()), 1) + self.assertTrue(self.user2 in item.subscribers()) + + self.assertFalse(Subscription.unsubscribe(self.user1, item)) class TestRSForm(TestCase): ''' Testing RSForm wrapper. ''' diff --git a/rsconcept/backend/apps/rsform/tests/t_views.py b/rsconcept/backend/apps/rsform/tests/t_views.py index 1d33fd18..b3d290f3 100644 --- a/rsconcept/backend/apps/rsform/tests/t_views.py +++ b/rsconcept/backend/apps/rsform/tests/t_views.py @@ -9,7 +9,7 @@ from rest_framework.exceptions import ErrorDetail from cctext import ReferenceType from apps.users.models import User -from apps.rsform.models import Syntax, RSForm, Constituenta, CstType, LibraryItem, LibraryItemType +from apps.rsform.models import Syntax, RSForm, Constituenta, CstType, LibraryItem, LibraryItemType, Subscription from apps.rsform.views import ( convert_to_ascii, convert_to_math, @@ -43,28 +43,28 @@ class TestConstituentaAPI(APITestCase): definition_raw='Test1', definition_resolved='Test2') def test_retrieve(self): - response = self.client.get(f'/api/constituents/{self.cst1.id}/') + response = self.client.get(f'/api/constituents/{self.cst1.id}') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['alias'], self.cst1.alias) self.assertEqual(response.data['convention'], self.cst1.convention) def test_partial_update(self): data = json.dumps({'convention': 'tt'}) - response = self.client.patch(f'/api/constituents/{self.cst2.id}/', data, content_type='application/json') + response = self.client.patch(f'/api/constituents/{self.cst2.id}', data, content_type='application/json') self.assertEqual(response.status_code, 403) self.client.logout() - response = self.client.patch(f'/api/constituents/{self.cst1.id}/', data, content_type='application/json') + response = self.client.patch(f'/api/constituents/{self.cst1.id}', data, content_type='application/json') self.assertEqual(response.status_code, 403) self.client.force_authenticate(user=self.user) - response = self.client.patch(f'/api/constituents/{self.cst1.id}/', data, content_type='application/json') + response = self.client.patch(f'/api/constituents/{self.cst1.id}', data, content_type='application/json') self.assertEqual(response.status_code, 200) self.cst1.refresh_from_db() self.assertEqual(response.data['convention'], 'tt') self.assertEqual(self.cst1.convention, 'tt') - response = self.client.patch(f'/api/constituents/{self.cst1.id}/', data, content_type='application/json') + response = self.client.patch(f'/api/constituents/{self.cst1.id}', data, content_type='application/json') self.assertEqual(response.status_code, 200) def test_update_resolved_norefs(self): @@ -72,7 +72,7 @@ class TestConstituentaAPI(APITestCase): 'term_raw': 'New term', 'definition_raw': 'New def' }) - response = self.client.patch(f'/api/constituents/{self.cst3.id}/', data, content_type='application/json') + response = self.client.patch(f'/api/constituents/{self.cst3.id}', data, content_type='application/json') self.assertEqual(response.status_code, 200) self.cst3.refresh_from_db() self.assertEqual(response.data['term_resolved'], 'New term') @@ -85,7 +85,7 @@ class TestConstituentaAPI(APITestCase): 'term_raw': '@{X1|nomn,sing}', 'definition_raw': '@{X1|nomn,sing} @{X1|sing,datv}' }) - response = self.client.patch(f'/api/constituents/{self.cst3.id}/', data, content_type='application/json') + response = self.client.patch(f'/api/constituents/{self.cst3.id}', data, content_type='application/json') self.assertEqual(response.status_code, 200) self.cst3.refresh_from_db() self.assertEqual(self.cst3.term_resolved, self.cst1.term_resolved) @@ -95,7 +95,7 @@ class TestConstituentaAPI(APITestCase): def test_readonly_cst_fields(self): data = json.dumps({'alias': 'X33', 'order': 10}) - response = self.client.patch(f'/api/constituents/{self.cst1.id}/', data, content_type='application/json') + response = self.client.patch(f'/api/constituents/{self.cst1.id}', data, content_type='application/json') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['alias'], 'X1') self.assertEqual(response.data['alias'], self.cst1.alias) @@ -130,19 +130,19 @@ class TestLibraryViewset(APITestCase): def test_create_anonymous(self): self.client.logout() data = json.dumps({'title': 'Title'}) - response = self.client.post('/api/library/', data=data, content_type='application/json') + response = self.client.post('/api/library', data=data, content_type='application/json') self.assertEqual(response.status_code, 403) def test_create_populate_user(self): data = json.dumps({'title': 'Title'}) - response = self.client.post('/api/library/', data=data, content_type='application/json') + response = self.client.post('/api/library', data=data, content_type='application/json') self.assertEqual(response.status_code, 201) self.assertEqual(response.data['title'], 'Title') self.assertEqual(response.data['owner'], self.user.id) def test_update(self): data = json.dumps({'id': self.owned.id, 'title': 'New title'}) - response = self.client.patch(f'/api/library/{self.owned.id}/', + response = self.client.patch(f'/api/library/{self.owned.id}', data=data, content_type='application/json') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['title'], 'New title') @@ -150,61 +150,91 @@ class TestLibraryViewset(APITestCase): def test_update_unowned(self): data = json.dumps({'id': self.unowned.id, 'title': 'New title'}) - response = self.client.patch(f'/api/library/{self.unowned.id}/', + response = self.client.patch(f'/api/library/{self.unowned.id}', data=data, content_type='application/json') self.assertEqual(response.status_code, 403) def test_destroy(self): - response = self.client.delete(f'/api/library/{self.owned.id}/') + response = self.client.delete(f'/api/library/{self.owned.id}') self.assertTrue(response.status_code in [202, 204]) def test_destroy_admin_override(self): - response = self.client.delete(f'/api/library/{self.unowned.id}/') + response = self.client.delete(f'/api/library/{self.unowned.id}') self.assertEqual(response.status_code, 403) self.user.is_staff = True self.user.save() - response = self.client.delete(f'/api/library/{self.unowned.id}/') + response = self.client.delete(f'/api/library/{self.unowned.id}') self.assertTrue(response.status_code in [202, 204]) def test_claim(self): - response = self.client.post(f'/api/library/{self.owned.id}/claim/') + response = self.client.post(f'/api/library/{self.owned.id}/claim') self.assertEqual(response.status_code, 403) self.owned.is_common = True self.owned.save() - response = self.client.post(f'/api/library/{self.owned.id}/claim/') + response = self.client.post(f'/api/library/{self.owned.id}/claim') self.assertEqual(response.status_code, 304) - response = self.client.post(f'/api/library/{self.unowned.id}/claim/') + response = self.client.post(f'/api/library/{self.unowned.id}/claim') self.assertEqual(response.status_code, 403) + self.assertFalse(self.user in self.unowned.subscribers()) self.unowned.is_common = True self.unowned.save() - response = self.client.post(f'/api/library/{self.unowned.id}/claim/') + response = self.client.post(f'/api/library/{self.unowned.id}/claim') self.assertEqual(response.status_code, 200) self.unowned.refresh_from_db() self.assertEqual(self.unowned.owner, self.user) + self.assertEqual(self.unowned.owner, self.user) + self.assertTrue(self.user in self.unowned.subscribers()) def test_claim_anonymous(self): self.client.logout() - response = self.client.post(f'/api/library/{self.owned.id}/claim/') + response = self.client.post(f'/api/library/{self.owned.id}/claim') self.assertEqual(response.status_code, 403) def test_retrieve_common(self): self.client.logout() - response = self.client.get('/api/library/active/') + response = self.client.get('/api/library/active') self.assertEqual(response.status_code, 200) self.assertTrue(_response_contains(response, self.common)) self.assertFalse(_response_contains(response, self.unowned)) self.assertFalse(_response_contains(response, self.owned)) def test_retrieve_owned(self): - response = self.client.get('/api/library/active/') + response = self.client.get('/api/library/active') self.assertEqual(response.status_code, 200) self.assertTrue(_response_contains(response, self.common)) self.assertFalse(_response_contains(response, self.unowned)) self.assertTrue(_response_contains(response, self.owned)) + def test_retrieve_subscribed(self): + response = self.client.get('/api/library/active') + self.assertEqual(response.status_code, 200) + self.assertFalse(_response_contains(response, self.unowned)) + + Subscription.subscribe(user=self.user, item=self.unowned) + response = self.client.get('/api/library/active') + self.assertEqual(response.status_code, 200) + self.assertTrue(_response_contains(response, self.unowned)) + + def test_subscriptions(self): + response = self.client.delete(f'/api/library/{self.unowned.id}/unsubscribe') + self.assertEqual(response.status_code, 200) + self.assertFalse(self.user in self.unowned.subscribers()) + + response = self.client.post(f'/api/library/{self.unowned.id}/subscribe') + self.assertEqual(response.status_code, 200) + self.assertTrue(self.user in self.unowned.subscribers()) + + response = self.client.post(f'/api/library/{self.unowned.id}/subscribe') + self.assertEqual(response.status_code, 200) + self.assertTrue(self.user in self.unowned.subscribers()) + + response = self.client.delete(f'/api/library/{self.unowned.id}/unsubscribe') + self.assertEqual(response.status_code, 200) + self.assertFalse(self.user in self.unowned.subscribers()) + class TestRSFormViewset(APITestCase): ''' Testing RSForm view. ''' @@ -221,13 +251,13 @@ class TestRSFormViewset(APITestCase): item_type=LibraryItemType.OPERATIONS_SCHEMA, title='Test3' ) - response = self.client.get('/api/rsforms/') + response = self.client.get('/api/rsforms') self.assertEqual(response.status_code, 200) self.assertFalse(_response_contains(response, non_schema)) self.assertTrue(_response_contains(response, self.unowned.item)) self.assertTrue(_response_contains(response, self.owned.item)) - response = self.client.get('/api/library/') + response = self.client.get('/api/library') self.assertEqual(response.status_code, 200) self.assertTrue(_response_contains(response, non_schema)) self.assertTrue(_response_contains(response, self.unowned.item)) @@ -236,11 +266,11 @@ class TestRSFormViewset(APITestCase): def test_contents(self): schema = RSForm.create(title='Title1') schema.insert_last(alias='X1', insert_type=CstType.BASE) - response = self.client.get(f'/api/rsforms/{schema.item.id}/contents/') + response = self.client.get(f'/api/rsforms/{schema.item.id}/contents') self.assertEqual(response.status_code, 200) def test_details(self): - schema = RSForm.create(title='Test') + schema = RSForm.create(title='Test', owner=self.user) x1 = schema.insert_at(1, 'X1', CstType.BASE) x2 = schema.insert_at(2, 'X2', CstType.BASE) x1.term_raw = 'человек' @@ -250,7 +280,7 @@ class TestRSFormViewset(APITestCase): x1.save() x2.save() - response = self.client.get(f'/api/rsforms/{schema.item.id}/details/') + response = self.client.get(f'/api/rsforms/{schema.item.id}/details') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['title'], 'Test') @@ -262,12 +292,13 @@ class TestRSFormViewset(APITestCase): self.assertEqual(response.data['items'][1]['id'], x2.id) self.assertEqual(response.data['items'][1]['term']['raw'], x2.term_raw) self.assertEqual(response.data['items'][1]['term']['resolved'], x2.term_resolved) + self.assertEqual(response.data['subscribers'], [self.user.pk]) def test_check(self): schema = RSForm.create(title='Test') schema.insert_at(1, 'X1', CstType.BASE) data = json.dumps({'expression': 'X1=X1'}) - response = self.client.post(f'/api/rsforms/{schema.item.id}/check/', data=data, content_type='application/json') + response = self.client.post(f'/api/rsforms/{schema.item.id}/check', data=data, content_type='application/json') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['parseResult'], True) self.assertEqual(response.data['syntax'], Syntax.MATH) @@ -281,7 +312,7 @@ class TestRSFormViewset(APITestCase): x1.term_resolved = 'синий слон' x1.save() data = json.dumps({'text': '@{1|редкий} @{X1|plur,datv}'}) - response = self.client.post(f'/api/rsforms/{schema.item.id}/resolve/', data=data, content_type='application/json') + response = self.client.post(f'/api/rsforms/{schema.item.id}/resolve', data=data, content_type='application/json') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['input'], '@{1|редкий} @{X1|plur,datv}') self.assertEqual(response.data['output'], 'редким синим слонам') @@ -307,7 +338,7 @@ class TestRSFormViewset(APITestCase): work_dir = os.path.dirname(os.path.abspath(__file__)) with open(f'{work_dir}/data/sample-rsform.trs', 'rb') as file: data = {'file': file} - response = self.client.post('/api/rsforms/import-trs/', data=data, format='multipart') + response = self.client.post('/api/rsforms/import-trs', data=data, format='multipart') self.assertEqual(response.status_code, 201) self.assertEqual(response.data['owner'], self.user.pk) self.assertTrue(response.data['title'] != '') @@ -315,7 +346,7 @@ class TestRSFormViewset(APITestCase): def test_export_trs(self): schema = RSForm.create(title='Test') schema.insert_at(1, 'X1', CstType.BASE) - response = self.client.get(f'/api/rsforms/{schema.item.id}/export-trs/') + response = self.client.get(f'/api/rsforms/{schema.item.id}/export-trs') self.assertEqual(response.status_code, 200) self.assertEqual(response.headers['Content-Disposition'], 'attachment; filename=Schema.trs') with io.BytesIO(response.content) as stream: @@ -325,14 +356,14 @@ class TestRSFormViewset(APITestCase): def test_create_constituenta(self): data = json.dumps({'alias': 'X3', 'cst_type': 'basic'}) - response = self.client.post(f'/api/rsforms/{self.unowned.item.id}/cst-create/', + response = self.client.post(f'/api/rsforms/{self.unowned.item.id}/cst-create', data=data, content_type='application/json') self.assertEqual(response.status_code, 403) item = self.owned.item Constituenta.objects.create(schema=item, alias='X1', cst_type='basic', order=1) x2 = Constituenta.objects.create(schema=item, alias='X2', cst_type='basic', order=2) - response = self.client.post(f'/api/rsforms/{item.id}/cst-create/', + response = self.client.post(f'/api/rsforms/{item.id}/cst-create', data=data, content_type='application/json') self.assertEqual(response.status_code, 201) self.assertEqual(response.data['new_cst']['alias'], 'X3') @@ -340,7 +371,7 @@ class TestRSFormViewset(APITestCase): self.assertEqual(x3.order, 3) data = json.dumps({'alias': 'X4', 'cst_type': 'basic', 'insert_after': x2.id}) - response = self.client.post(f'/api/rsforms/{item.id}/cst-create/', + response = self.client.post(f'/api/rsforms/{item.id}/cst-create', data=data, content_type='application/json') self.assertEqual(response.status_code, 201) self.assertEqual(response.data['new_cst']['alias'], 'X4') @@ -361,16 +392,16 @@ class TestRSFormViewset(APITestCase): definition_raw='Test1', definition_resolved='Test2') data = json.dumps({'alias': 'D2', 'cst_type': 'term', 'id': self.cst2.pk}) - response = self.client.patch(f'/api/rsforms/{self.unowned.item.id}/cst-rename/', + response = self.client.patch(f'/api/rsforms/{self.unowned.item.id}/cst-rename', data=data, content_type='application/json') self.assertEqual(response.status_code, 403) - response = self.client.patch(f'/api/rsforms/{self.owned.item.id}/cst-rename/', + response = self.client.patch(f'/api/rsforms/{self.owned.item.id}/cst-rename', data=data, content_type='application/json') self.assertEqual(response.status_code, 400) data = json.dumps({'alias': self.cst1.alias, 'cst_type': 'term', 'id': self.cst1.pk}) - response = self.client.patch(f'/api/rsforms/{self.owned.item.id}/cst-rename/', + response = self.client.patch(f'/api/rsforms/{self.owned.item.id}/cst-rename', data=data, content_type='application/json') self.assertEqual(response.status_code, 400) @@ -384,7 +415,7 @@ class TestRSFormViewset(APITestCase): self.assertEqual(self.cst1.order, 1) self.assertEqual(self.cst1.alias, 'X1') self.assertEqual(self.cst1.cst_type, CstType.BASE) - response = self.client.patch(f'/api/rsforms/{item.id}/cst-rename/', + response = self.client.patch(f'/api/rsforms/{item.id}/cst-rename', data=data, content_type='application/json') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['new_cst']['alias'], 'D2') @@ -407,7 +438,7 @@ class TestRSFormViewset(APITestCase): 'definition_raw': '4' }) item = self.owned.item - response = self.client.post(f'/api/rsforms/{item.id}/cst-create/', + response = self.client.post(f'/api/rsforms/{item.id}/cst-create', data=data, content_type='application/json') self.assertEqual(response.status_code, 201) self.assertEqual(response.data['new_cst']['alias'], 'X3') @@ -422,14 +453,14 @@ class TestRSFormViewset(APITestCase): def test_delete_constituenta(self): schema = self.owned data = json.dumps({'items': [{'id': 1337}]}) - response = self.client.patch(f'/api/rsforms/{schema.item.id}/cst-multidelete/', + response = self.client.patch(f'/api/rsforms/{schema.item.id}/cst-multidelete', data=data, content_type='application/json') self.assertEqual(response.status_code, 400) x1 = Constituenta.objects.create(schema=schema.item, alias='X1', cst_type='basic', order=1) x2 = Constituenta.objects.create(schema=schema.item, alias='X2', cst_type='basic', order=2) data = json.dumps({'items': [{'id': x1.id}]}) - response = self.client.patch(f'/api/rsforms/{schema.item.id}/cst-multidelete/', + response = self.client.patch(f'/api/rsforms/{schema.item.id}/cst-multidelete', data=data, content_type='application/json') x2.refresh_from_db() schema.item.refresh_from_db() @@ -441,21 +472,21 @@ class TestRSFormViewset(APITestCase): x3 = Constituenta.objects.create(schema=self.unowned.item, alias='X1', cst_type='basic', order=1) data = json.dumps({'items': [{'id': x3.id}]}) - response = self.client.patch(f'/api/rsforms/{schema.item.id}/cst-multidelete/', + response = self.client.patch(f'/api/rsforms/{schema.item.id}/cst-multidelete', data=data, content_type='application/json') self.assertEqual(response.status_code, 400) def test_move_constituenta(self): item = self.owned.item data = json.dumps({'items': [{'id': 1337}], 'move_to': 1}) - response = self.client.patch(f'/api/rsforms/{item.id}/cst-moveto/', + response = self.client.patch(f'/api/rsforms/{item.id}/cst-moveto', data=data, content_type='application/json') self.assertEqual(response.status_code, 400) x1 = Constituenta.objects.create(schema=item, alias='X1', cst_type='basic', order=1) x2 = Constituenta.objects.create(schema=item, alias='X2', cst_type='basic', order=2) data = json.dumps({'items': [{'id': x2.id}], 'move_to': 1}) - response = self.client.patch(f'/api/rsforms/{item.id}/cst-moveto/', + response = self.client.patch(f'/api/rsforms/{item.id}/cst-moveto', data=data, content_type='application/json') x1.refresh_from_db() x2.refresh_from_db() @@ -466,20 +497,20 @@ class TestRSFormViewset(APITestCase): x3 = Constituenta.objects.create(schema=self.unowned.item, alias='X1', cst_type='basic', order=1) data = json.dumps({'items': [{'id': x3.id}], 'move_to': 1}) - response = self.client.patch(f'/api/rsforms/{item.id}/cst-moveto/', + response = self.client.patch(f'/api/rsforms/{item.id}/cst-moveto', data=data, content_type='application/json') self.assertEqual(response.status_code, 400) def test_reset_aliases(self): item = self.owned.item - response = self.client.patch(f'/api/rsforms/{item.id}/reset-aliases/') + response = self.client.patch(f'/api/rsforms/{item.id}/reset-aliases') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['id'], item.id) x2 = Constituenta.objects.create(schema=item, alias='X2', cst_type='basic', order=1) x1 = Constituenta.objects.create(schema=item, alias='X1', cst_type='basic', order=2) d11 = Constituenta.objects.create(schema=item, alias='D11', cst_type='term', order=3) - response = self.client.patch(f'/api/rsforms/{item.id}/reset-aliases/') + response = self.client.patch(f'/api/rsforms/{item.id}/reset-aliases') x1.refresh_from_db() x2.refresh_from_db() d11.refresh_from_db() @@ -491,7 +522,7 @@ class TestRSFormViewset(APITestCase): self.assertEqual(d11.order, 3) self.assertEqual(d11.alias, 'D1') - response = self.client.patch(f'/api/rsforms/{item.id}/reset-aliases/') + response = self.client.patch(f'/api/rsforms/{item.id}/reset-aliases') self.assertEqual(response.status_code, 200) def test_load_trs(self): @@ -502,13 +533,13 @@ class TestRSFormViewset(APITestCase): work_dir = os.path.dirname(os.path.abspath(__file__)) with open(f'{work_dir}/data/sample-rsform.trs', 'rb') as file: data = {'file': file, 'load_metadata': False} - response = self.client.patch(f'/api/rsforms/{schema.item.id}/load-trs/', data=data, format='multipart') + response = self.client.patch(f'/api/rsforms/{schema.item.id}/load-trs', data=data, format='multipart') schema.item.refresh_from_db() self.assertEqual(response.status_code, 200) self.assertEqual(schema.item.title, 'Testt11') self.assertEqual(len(response.data['items']), 25) self.assertEqual(schema.constituents().count(), 25) - self.assertFalse(Constituenta.objects.all().filter(pk=x1.id).exists()) + self.assertFalse(Constituenta.objects.filter(pk=x1.id).exists()) def test_clone(self): item = self.owned.item @@ -524,7 +555,7 @@ class TestRSFormViewset(APITestCase): d1.save() data = json.dumps({'title': 'Title'}) - response = self.client.post(f'/api/library/{item.id}/clone/', data=data, content_type='application/json') + response = self.client.post(f'/api/library/{item.id}/clone', data=data, content_type='application/json') self.assertEqual(response.status_code, 201) self.assertEqual(response.data['title'], 'Title') @@ -546,7 +577,7 @@ class TestFunctionalViews(APITestCase): work_dir = os.path.dirname(os.path.abspath(__file__)) with open(f'{work_dir}/data/sample-rsform.trs', 'rb') as file: data = {'file': file, 'title': 'Test123', 'comment': '123', 'alias': 'ks1'} - response = self.client.post('/api/rsforms/create-detailed/', data=data, format='multipart') + response = self.client.post('/api/rsforms/create-detailed', data=data, format='multipart') self.assertEqual(response.status_code, 201) self.assertEqual(response.data['owner'], self.user.pk) self.assertEqual(response.data['title'], 'Test123') @@ -555,7 +586,7 @@ class TestFunctionalViews(APITestCase): def test_create_rsform_fallback(self): data = {'title': 'Test123', 'comment': '123', 'alias': 'ks1'} - response = self.client.post('/api/rsforms/create-detailed/', data=data) + response = self.client.post('/api/rsforms/create-detailed', data=data) self.assertEqual(response.status_code, 201) self.assertEqual(response.data['owner'], self.user.pk) self.assertEqual(response.data['title'], 'Test123') diff --git a/rsconcept/backend/apps/rsform/urls.py b/rsconcept/backend/apps/rsform/urls.py index 289c1f98..9fb6b76b 100644 --- a/rsconcept/backend/apps/rsform/urls.py +++ b/rsconcept/backend/apps/rsform/urls.py @@ -3,17 +3,17 @@ from django.urls import path, include from rest_framework import routers from . import views -library_router = routers.SimpleRouter() +library_router = routers.SimpleRouter(trailing_slash=False) library_router.register('library', views.LibraryViewSet) library_router.register('rsforms', views.RSFormViewSet) urlpatterns = [ - path('library/active/', views.LibraryActiveView.as_view(), name='library'), - path('constituents//', views.ConstituentAPIView.as_view(), name='constituenta-detail'), - path('rsforms/import-trs/', views.TrsImportView.as_view()), - path('rsforms/create-detailed/', views.create_rsform), - path('func/parse-expression/', views.parse_expression), - path('func/to-ascii/', views.convert_to_ascii), - path('func/to-math/', views.convert_to_math), + path('library/active', views.LibraryActiveView.as_view(), name='library'), + path('constituents/', views.ConstituentAPIView.as_view(), name='constituenta-detail'), + path('rsforms/import-trs', views.TrsImportView.as_view()), + path('rsforms/create-detailed', views.create_rsform), + path('func/parse-expression', views.parse_expression), + path('func/to-ascii', views.convert_to_ascii), + path('func/to-math', views.convert_to_math), path('', include(library_router.urls)), ] diff --git a/rsconcept/backend/apps/rsform/views.py b/rsconcept/backend/apps/rsform/views.py index 9dd0e311..7d45d86a 100644 --- a/rsconcept/backend/apps/rsform/views.py +++ b/rsconcept/backend/apps/rsform/views.py @@ -24,7 +24,8 @@ class LibraryActiveView(generics.ListAPIView): def get_queryset(self): user = self.request.user if not user.is_anonymous: - return m.LibraryItem.objects.filter(Q(is_common=True) | Q(owner=user)) + # pyling: disable=unsupported-binary-operation + return m.LibraryItem.objects.filter(Q(is_common=True) | Q(owner=user) | Q(subscription__user=user)) else: return m.LibraryItem.objects.filter(is_common=True) @@ -62,7 +63,7 @@ class LibraryViewSet(viewsets.ModelViewSet): def get_permissions(self): if self.action in ['update', 'destroy', 'partial_update']: permission_classes = [utils.ObjectOwnerOrAdmin] - elif self.action in ['create', 'clone']: + elif self.action in ['create', 'clone', 'subscribe', 'unsubscribe']: permission_classes = [permissions.IsAuthenticated] elif self.action in ['claim']: permission_classes = [utils.IsClaimable] @@ -70,13 +71,16 @@ class LibraryViewSet(viewsets.ModelViewSet): permission_classes = [permissions.AllowAny] return [permission() for permission in permission_classes] + def _get_item(self) -> m.LibraryItem: + return cast(m.LibraryItem, self.get_object()) + @transaction.atomic @action(detail=True, methods=['post'], url_path='clone') def clone(self, request, pk): ''' Endpoint: Create deep copy of library item. ''' serializer = s.LibraryItemSerializer(data=request.data) serializer.is_valid(raise_exception=True) - item = cast(m.LibraryItem, self.get_object()) + item = self._get_item() if item.item_type == m.LibraryItemType.RSFORM: schema = m.RSForm(item) clone_data = s.RSFormTRSSerializer(schema).data @@ -93,21 +97,37 @@ class LibraryViewSet(viewsets.ModelViewSet): return Response(status=201, data=m.PyConceptAdapter(new_schema).full()) return Response(status=404) + @transaction.atomic @action(detail=True, methods=['post']) def claim(self, request, pk=None): ''' Endpoint: Claim ownership of LibraryItem. ''' - item = cast(m.LibraryItem, self.get_object()) + item = self._get_item() if item.owner == self.request.user: return Response(status=304) else: item.owner = self.request.user item.save() + m.Subscription.subscribe(user=item.owner, item=item) return Response(status=200, data=s.LibraryItemSerializer(item).data) + @action(detail=True, methods=['post']) + def subscribe(self, request, pk): + ''' Endpoint: Subscribe current user to item. ''' + item = self._get_item() + m.Subscription.subscribe(user=self.request.user, item=item) + return Response(status=200) + + @action(detail=True, methods=['delete']) + def unsubscribe(self, request, pk): + ''' Endpoint: Unsubscribe current user from item. ''' + item = self._get_item() + m.Subscription.unsubscribe(user=self.request.user, item=item) + return Response(status=200) + class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.RetrieveAPIView): ''' Endpoint: RSForm operations. ''' - queryset = m.LibraryItem.objects.all().filter(item_type=m.LibraryItemType.RSFORM) + queryset = m.LibraryItem.objects.filter(item_type=m.LibraryItemType.RSFORM) serializer_class = s.LibraryItemSerializer def _get_schema(self) -> m.RSForm: diff --git a/rsconcept/backend/apps/users/serializers.py b/rsconcept/backend/apps/users/serializers.py index 30ad8170..6ddc6618 100644 --- a/rsconcept/backend/apps/users/serializers.py +++ b/rsconcept/backend/apps/users/serializers.py @@ -3,6 +3,7 @@ from django.contrib.auth import authenticate from django.contrib.auth.password_validation import validate_password from rest_framework import serializers +from apps.rsform.models import Subscription from . import models @@ -40,16 +41,23 @@ class LoginSerializer(serializers.Serializer): raise NotImplementedError('unexpected `update()` call') -class AuthSerializer(serializers.ModelSerializer): +class AuthSerializer(serializers.Serializer): ''' Serializer: Authentication data. ''' - class Meta: - ''' serializer metadata. ''' - model = models.User - fields = [ - 'id', - 'username', - 'is_staff' - ] + def to_representation(self, instance: models.User) -> dict: + if instance.is_anonymous: + return { + 'id': None, + 'username': '', + 'is_staff': False, + 'subscriptions': [] + } + else: + return { + 'id': instance.pk, + 'username': instance.username, + 'is_staff': instance.is_staff, + 'subscriptions': [sub.item.pk for sub in Subscription.objects.filter(user=instance)] + } class UserInfoSerializer(serializers.ModelSerializer): diff --git a/rsconcept/backend/apps/users/tests/t_views.py b/rsconcept/backend/apps/users/tests/t_views.py index 6187b844..1f755911 100644 --- a/rsconcept/backend/apps/users/tests/t_views.py +++ b/rsconcept/backend/apps/users/tests/t_views.py @@ -3,9 +3,10 @@ import json from rest_framework.test import APITestCase, APIClient from apps.users.models import User +from apps.rsform.models import LibraryItem, LibraryItemType -# TODO: test AUTH and ATIVE_USERS +# TODO: test ACTIVE_USERS class TestUserAPIViews(APITestCase): def setUp(self): self.username = 'UserTest' @@ -30,6 +31,30 @@ class TestUserAPIViews(APITestCase): self.assertEqual(self.client.post('/users/api/logout').status_code, 403) + def test_auth(self): + LibraryItem.objects.create(item_type=LibraryItemType.RSFORM, title='T1') + item = LibraryItem.objects.create( + item_type=LibraryItemType.RSFORM, + title='Test', + alias='T1', + is_common=True, + owner=self.user + ) + response = self.client.get('/users/api/auth') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['id'], None) + self.assertEqual(response.data['username'], '') + self.assertEqual(response.data['is_staff'], False) + self.assertEqual(response.data['subscriptions'], []) + + self.client.force_login(self.user) + response = self.client.get('/users/api/auth') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['id'], self.user.pk) + self.assertEqual(response.data['username'], self.user.username) + self.assertEqual(response.data['is_staff'], self.user.is_staff) + self.assertEqual(response.data['subscriptions'], [item.pk]) + class TestUserUserProfileAPIView(APITestCase): def setUp(self): diff --git a/rsconcept/backend/apps/users/views.py b/rsconcept/backend/apps/users/views.py index 56055cb8..6cd097e8 100644 --- a/rsconcept/backend/apps/users/views.py +++ b/rsconcept/backend/apps/users/views.py @@ -8,9 +8,7 @@ from . import serializers from . import models class LoginAPIView(views.APIView): - ''' - Endpoint: Login user via username + password. - ''' + ''' Endpoint: Login via username + password. ''' permission_classes = (permissions.AllowAny,) def post(self, request): @@ -25,9 +23,7 @@ class LoginAPIView(views.APIView): class LogoutAPIView(views.APIView): - ''' - Endpoint: Logout current user. - ''' + ''' Endpoint: Logout. ''' permission_classes = (permissions.IsAuthenticated,) def post(self, request): @@ -36,17 +32,13 @@ class LogoutAPIView(views.APIView): class SignupAPIView(generics.CreateAPIView): - ''' - Register user. - ''' + ''' Endpoint: Register user. ''' permission_classes = (permissions.AllowAny, ) serializer_class = serializers.SignupSerializer class AuthAPIView(generics.RetrieveAPIView): - ''' - Get current user authentification ID. - ''' + ''' Endpoint: Current user info. ''' permission_classes = (permissions.AllowAny,) serializer_class = serializers.AuthSerializer @@ -55,9 +47,7 @@ class AuthAPIView(generics.RetrieveAPIView): class ActiveUsersView(generics.ListAPIView): - ''' - Endpoint: Get list of active users. - ''' + ''' Endpoint: Get list of active users. ''' permission_classes = (permissions.AllowAny,) serializer_class = serializers.UserSerializer @@ -66,9 +56,7 @@ class ActiveUsersView(generics.ListAPIView): class UserProfileAPIView(generics.RetrieveUpdateAPIView): - ''' - Endpoint: User profile info. - ''' + ''' Endpoint: User profile. ''' permission_classes = (permissions.IsAuthenticated,) serializer_class = serializers.UserSerializer @@ -77,9 +65,7 @@ class UserProfileAPIView(generics.RetrieveUpdateAPIView): class UpdatePassword(views.APIView): - ''' - Endpoint: Change password for current user. - ''' + ''' Endpoint: Change password for current user. ''' permission_classes = (permissions.IsAuthenticated, ) def get_object(self, queryset=None): diff --git a/rsconcept/backend/fixtures/InitialData.json b/rsconcept/backend/fixtures/InitialData.json index 3c07aa53..8fbeddf6 100644 --- a/rsconcept/backend/fixtures/InitialData.json +++ b/rsconcept/backend/fixtures/InitialData.json @@ -159,7 +159,7 @@ "alias": "M0005", "comment": "", "is_common": true, - "is_canonical": true, + "is_canonical": false, "time_create": "2023-08-25T19:03:40.052Z", "time_update": "2023-08-25T19:03:40.052Z" } @@ -174,7 +174,7 @@ "alias": "M0006", "comment": "", "is_common": true, - "is_canonical": true, + "is_canonical": false, "time_create": "2023-08-25T19:03:40.066Z", "time_update": "2023-08-25T19:03:40.066Z" } @@ -189,7 +189,7 @@ "alias": "M0007", "comment": "", "is_common": true, - "is_canonical": true, + "is_canonical": false, "time_create": "2023-08-25T19:03:40.077Z", "time_update": "2023-08-25T19:03:40.077Z" } @@ -204,7 +204,7 @@ "alias": "M0008", "comment": "", "is_common": true, - "is_canonical": true, + "is_canonical": false, "time_create": "2023-08-25T19:03:40.081Z", "time_update": "2023-08-25T19:03:40.081Z" } @@ -219,7 +219,7 @@ "alias": "M0009", "comment": "", "is_common": true, - "is_canonical": true, + "is_canonical": false, "time_create": "2023-08-25T19:03:40.094Z", "time_update": "2023-08-25T19:03:40.095Z" } @@ -234,7 +234,7 @@ "alias": "M0010", "comment": "", "is_common": true, - "is_canonical": true, + "is_canonical": false, "time_create": "2023-08-25T19:03:40.118Z", "time_update": "2023-08-25T19:03:40.118Z" } @@ -249,7 +249,7 @@ "alias": "M0011", "comment": "", "is_common": true, - "is_canonical": true, + "is_canonical": false, "time_create": "2023-08-25T19:03:40.132Z", "time_update": "2023-08-25T19:03:40.132Z" } @@ -264,7 +264,7 @@ "alias": "M0012", "comment": "", "is_common": true, - "is_canonical": true, + "is_canonical": false, "time_create": "2023-08-25T19:03:40.152Z", "time_update": "2023-08-25T19:03:40.152Z" } @@ -279,7 +279,7 @@ "alias": "M0013", "comment": "", "is_common": true, - "is_canonical": true, + "is_canonical": false, "time_create": "2023-08-25T19:03:40.167Z", "time_update": "2023-08-25T19:03:40.168Z" } @@ -294,7 +294,7 @@ "alias": "M0014", "comment": "", "is_common": true, - "is_canonical": true, + "is_canonical": false, "time_create": "2023-08-25T19:03:40.196Z", "time_update": "2023-08-25T19:03:40.196Z" } @@ -309,7 +309,7 @@ "alias": "M0015", "comment": "", "is_common": true, - "is_canonical": true, + "is_canonical": false, "time_create": "2023-08-25T19:03:40.207Z", "time_update": "2023-08-25T19:03:40.207Z" } @@ -324,7 +324,7 @@ "alias": "M0016", "comment": "", "is_common": true, - "is_canonical": true, + "is_canonical": false, "time_create": "2023-08-25T19:03:40.258Z", "time_update": "2023-08-25T19:03:40.258Z" } @@ -354,7 +354,7 @@ "alias": "D0002", "comment": "", "is_common": true, - "is_canonical": true, + "is_canonical": false, "time_create": "2023-08-25T19:03:40.459Z", "time_update": "2023-08-25T19:03:40.460Z" } diff --git a/rsconcept/frontend/src/components/Common/TextArea.tsx b/rsconcept/frontend/src/components/Common/TextArea.tsx index 2b7ffd9b..d2158ef2 100644 --- a/rsconcept/frontend/src/components/Common/TextArea.tsx +++ b/rsconcept/frontend/src/components/Common/TextArea.tsx @@ -3,14 +3,15 @@ import { TextareaHTMLAttributes } from 'react'; import Label from './Label'; export interface TextAreaProps -extends Omit, 'className'> { +extends Omit, 'className' | 'title'> { label: string + tooltip?: string widthClass?: string colorClass?: string } function TextArea({ - id, label, required, + id, label, required, tooltip, widthClass = 'w-full', colorClass = 'clr-input', rows = 4, @@ -24,10 +25,11 @@ function TextArea({ htmlFor={id} />