From ca4882575eca78008dc3a8cb9b6ef4ab27df5174 Mon Sep 17 00:00:00 2001 From: IRBorisov <8611739+IRBorisov@users.noreply.github.com> Date: Sun, 3 Mar 2024 22:00:22 +0300 Subject: [PATCH] Implementing versioning: backend createVersion --- rsconcept/backend/.env.dev | 3 +- rsconcept/backend/.env.prod | 3 +- rsconcept/backend/.env.prod.local | 3 +- rsconcept/backend/apps/rsform/admin.py | 9 + .../apps/rsform/migrations/0004_version.py | 30 +++ rsconcept/backend/apps/rsform/models.py | 50 ++++- rsconcept/backend/apps/rsform/serializers.py | 44 +++- .../backend/apps/rsform/tests/t_views.py | 200 +++++++++++------- rsconcept/backend/apps/rsform/urls.py | 1 + rsconcept/backend/apps/rsform/views.py | 152 ++++++++----- rsconcept/backend/cctext/ruparser.py | 6 +- rsconcept/backend/project/settings.py | 8 +- rsconcept/frontend/src/models/library.ts | 12 ++ 13 files changed, 377 insertions(+), 144 deletions(-) create mode 100644 rsconcept/backend/apps/rsform/migrations/0004_version.py diff --git a/rsconcept/backend/.env.dev b/rsconcept/backend/.env.dev index 9269dc32..6b6eb27a 100644 --- a/rsconcept/backend/.env.dev +++ b/rsconcept/backend/.env.dev @@ -33,4 +33,5 @@ DB_PASSWORD=78ACF6C4F3 # Debug settings DEBUG=1 PYTHONDEVMODE=1 -PYTHONTRACEMALLOC=1 \ No newline at end of file +PYTHONTRACEMALLOC=1 +DJANGO_LOG_LEVEL=DEBUG \ No newline at end of file diff --git a/rsconcept/backend/.env.prod b/rsconcept/backend/.env.prod index aa88ecd0..25e3709f 100644 --- a/rsconcept/backend/.env.prod +++ b/rsconcept/backend/.env.prod @@ -33,4 +33,5 @@ DB_PORT=5432 # Debug settings DEBUG=0 PYTHONDEVMODE=0 -PYTHONTRACEMALLOC=0 \ No newline at end of file +PYTHONTRACEMALLOC=0 +DJANGO_LOG_LEVEL=DEBUG \ No newline at end of file diff --git a/rsconcept/backend/.env.prod.local b/rsconcept/backend/.env.prod.local index 84268d38..bf516315 100644 --- a/rsconcept/backend/.env.prod.local +++ b/rsconcept/backend/.env.prod.local @@ -33,4 +33,5 @@ DB_PASSWORD=78ACF6C4F3 # Debug settings DEBUG=0 PYTHONDEVMODE=0 -PYTHONTRACEMALLOC=0 \ No newline at end of file +PYTHONTRACEMALLOC=0 +DJANGO_LOG_LEVEL=DEBUG \ No newline at end of file diff --git a/rsconcept/backend/apps/rsform/admin.py b/rsconcept/backend/apps/rsform/admin.py index c8e238c6..21eb50c1 100644 --- a/rsconcept/backend/apps/rsform/admin.py +++ b/rsconcept/backend/apps/rsform/admin.py @@ -44,7 +44,16 @@ class SubscriptionAdmin(admin.ModelAdmin): ] +class VersionAdmin(admin.ModelAdmin): + ''' Admin model: Versions. ''' + list_display = ['id', 'item', 'version', 'description', 'time_create'] + search_fields = [ + 'item__title', 'item__alias' + ] + + admin.site.register(models.Constituenta, ConstituentaAdmin) admin.site.register(models.LibraryItem, LibraryItemAdmin) admin.site.register(models.LibraryTemplate, LibraryTemplateAdmin) admin.site.register(models.Subscription, SubscriptionAdmin) +admin.site.register(models.Version, VersionAdmin) diff --git a/rsconcept/backend/apps/rsform/migrations/0004_version.py b/rsconcept/backend/apps/rsform/migrations/0004_version.py new file mode 100644 index 00000000..db57b43b --- /dev/null +++ b/rsconcept/backend/apps/rsform/migrations/0004_version.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.10 on 2024-03-03 10:57 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('rsform', '0003_alter_constituenta_definition_raw_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Version', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('version', models.CharField(max_length=20, verbose_name='Версия')), + ('description', models.TextField(blank=True, verbose_name='Описание')), + ('data', models.JSONField(verbose_name='Содержание')), + ('time_create', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='rsform.libraryitem', verbose_name='Схема')), + ], + options={ + 'verbose_name': 'Версии', + 'verbose_name_plural': 'Версия', + 'unique_together': {('item', 'version')}, + }, + ), + ] diff --git a/rsconcept/backend/apps/rsform/models.py b/rsconcept/backend/apps/rsform/models.py index b6dbaac2..ce3ff56f 100644 --- a/rsconcept/backend/apps/rsform/models.py +++ b/rsconcept/backend/apps/rsform/models.py @@ -125,9 +125,13 @@ class LibraryItem(Model): return f'/api/library/{self.pk}' def subscribers(self) -> list[User]: - ''' Get all subscribers for this item . ''' + ''' Get all subscribers for this item. ''' return [subscription.user for subscription in Subscription.objects.filter(item=self.pk)] + def versions(self) -> list['Version']: + ''' Get all Versions of this item. ''' + return list(Version.objects.filter(item=self.pk)) + @transaction.atomic def save(self, *args, **kwargs): subscribe = not self.pk and self.owner @@ -151,6 +155,40 @@ class LibraryTemplate(Model): verbose_name_plural = 'Шаблоны' +class Version(Model): + ''' Library item version archive. ''' + item: ForeignKey = ForeignKey( + verbose_name='Схема', + to=LibraryItem, + on_delete=CASCADE + ) + version = CharField( + verbose_name='Версия', + max_length=20, + blank=False + ) + description: TextField = TextField( + verbose_name='Описание', + blank=True + ) + data: JSONField = JSONField( + verbose_name='Содержание' + ) + time_create: DateTimeField = DateTimeField( + verbose_name='Дата создания', + auto_now_add=True + ) + + class Meta: + ''' Model metadata. ''' + verbose_name = 'Версии' + verbose_name_plural = 'Версия' + unique_together = [['item', 'version']] + + def __str__(self) -> str: + return f'{self.item} v{self.version}' + + class Subscription(Model): ''' User subscription to library item. ''' user: ForeignKey = ForeignKey( @@ -511,6 +549,16 @@ class RSForm: cst.definition_resolved = resolved cst.save() + @transaction.atomic + def create_version(self, version: str, description: str, data) -> Version: + ''' Creates version for current state. ''' + return Version.objects.create( + item=self.item, + version=version, + description=description, + data=data + ) + @transaction.atomic def _reset_order(self): order = 1 diff --git a/rsconcept/backend/apps/rsform/serializers.py b/rsconcept/backend/apps/rsform/serializers.py index 6e2083cc..d4fa7e94 100644 --- a/rsconcept/backend/apps/rsform/serializers.py +++ b/rsconcept/backend/apps/rsform/serializers.py @@ -8,7 +8,7 @@ import pyconcept from cctext import Resolver, Reference, ReferenceType, EntityReference, SyntacticReference from .utils import fix_old_references -from .models import Constituenta, LibraryItem, RSForm +from .models import Constituenta, LibraryItem, RSForm, Version from . import messages as msg _CST_TYPE = 'constituenta' @@ -128,9 +128,27 @@ class ExpressionParseSerializer(serializers.Serializer): ) +class VersionSerializer(serializers.ModelSerializer): + ''' Serializer: Version data. ''' + class Meta: + ''' serializer metadata. ''' + model = Version + fields = 'id', 'version', 'description', 'time_create' + read_only_fields = ('item', 'id', 'time_create') + + +class VersionCreateSerializer(serializers.ModelSerializer): + ''' Serializer: Version create data. ''' + class Meta: + ''' serializer metadata. ''' + model = Version + fields = 'version', 'description' + + class LibraryItemDetailsSerializer(serializers.ModelSerializer): ''' Serializer: LibraryItem detailed data. ''' subscribers = serializers.SerializerMethodField() + versions = serializers.SerializerMethodField() class Meta: ''' serializer metadata. ''' @@ -141,6 +159,9 @@ class LibraryItemDetailsSerializer(serializers.ModelSerializer): def get_subscribers(self, instance: LibraryItem) -> list[int]: return [item.pk for item in instance.subscribers()] + def get_versions(self, instance: LibraryItem) -> list: + return [VersionSerializer(item).data for item in instance.versions()] + class ConstituentaSerializer(serializers.ModelSerializer): ''' Serializer: Constituenta data. ''' @@ -398,13 +419,32 @@ class RSFormSerializer(serializers.ModelSerializer): model = LibraryItem fields = '__all__' - def to_representation(self, instance: LibraryItem): + def to_representation(self, instance: LibraryItem) -> dict: result = LibraryItemDetailsSerializer(instance).data schema = RSForm(instance) result['items'] = [] for cst in schema.constituents().order_by('order'): result['items'].append(ConstituentaSerializer(cst).data) return result + + def to_versioned_data(self) -> dict: + ''' Create serializable version representation without redundant data. ''' + result = self.to_representation(self.instance) + del result['versions'] + del result['subscribers'] + + del result['owner'] + del result['is_common'] + del result['is_canonical'] + del result['time_create'] + del result['time_update'] + return result + + def from_versioned_data(self, version: int, data: dict) -> dict: + ''' Load data from version. ''' + result = self.to_representation(self.instance) + result['version'] = version + return result | data class CstDetailsSerializer(serializers.ModelSerializer): diff --git a/rsconcept/backend/apps/rsform/tests/t_views.py b/rsconcept/backend/apps/rsform/tests/t_views.py index 1e7ed831..201479c7 100644 --- a/rsconcept/backend/apps/rsform/tests/t_views.py +++ b/rsconcept/backend/apps/rsform/tests/t_views.py @@ -4,6 +4,7 @@ import io from zipfile import ZipFile from rest_framework.test import APITestCase, APIRequestFactory, APIClient from rest_framework.exceptions import ErrorDetail +from rest_framework import status from cctext import ReferenceType, split_grams @@ -63,7 +64,7 @@ class TestConstituentaAPI(APITestCase): def test_retrieve(self): response = self.client.get(f'/api/constituents/{self.cst1.id}') - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['alias'], self.cst1.alias) self.assertEqual(response.data['convention'], self.cst1.convention) @@ -73,21 +74,21 @@ class TestConstituentaAPI(APITestCase): f'/api/constituents/{self.cst2.id}', data=data, format='json' ) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.client.logout() response = self.client.patch( f'/api/constituents/{self.cst1.id}', data=data, format='json' ) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.client.force_authenticate(user=self.user) response = self.client.patch( f'/api/constituents/{self.cst1.id}', data=data, format='json' ) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) self.cst1.refresh_from_db() self.assertEqual(response.data['convention'], 'tt') self.assertEqual(self.cst1.convention, 'tt') @@ -97,7 +98,7 @@ class TestConstituentaAPI(APITestCase): data=data, format='json' ) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) def test_update_resolved_no_refs(self): data = { @@ -105,7 +106,7 @@ class TestConstituentaAPI(APITestCase): 'definition_raw': 'New def' } response = self.client.patch(f'/api/constituents/{self.cst3.id}', data, format='json') - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) self.cst3.refresh_from_db() self.assertEqual(response.data['term_resolved'], 'New term') self.assertEqual(self.cst3.term_resolved, 'New term') @@ -121,7 +122,7 @@ class TestConstituentaAPI(APITestCase): f'/api/constituents/{self.cst3.id}', data=data, format='json' ) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) self.cst3.refresh_from_db() self.assertEqual(self.cst3.term_resolved, self.cst1.term_resolved) self.assertEqual(response.data['term_resolved'], self.cst1.term_resolved) @@ -134,7 +135,7 @@ class TestConstituentaAPI(APITestCase): f'/api/constituents/{self.cst1.id}', data=data, format='json' ) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['alias'], 'X1') self.assertEqual(response.data['alias'], self.cst1.alias) self.assertEqual(response.data['order'], self.cst1.order) @@ -169,12 +170,12 @@ class TestLibraryViewset(APITestCase): self.client.logout() data = {'title': 'Title'} response = self.client.post('/api/library', data=data, format='json') - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_create_populate_user(self): data = {'title': 'Title'} response = self.client.post('/api/library', data=data, format='json') - self.assertEqual(response.status_code, 201) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.data['title'], 'Title') self.assertEqual(response.data['owner'], self.user.id) @@ -184,7 +185,7 @@ class TestLibraryViewset(APITestCase): f'/api/library/{self.owned.id}', data=data, format='json' ) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['title'], 'New title') self.assertEqual(response.data['alias'], self.owned.alias) @@ -194,37 +195,37 @@ class TestLibraryViewset(APITestCase): f'/api/library/{self.unowned.id}', data=data, format='json' ) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_destroy(self): response = self.client.delete(f'/api/library/{self.owned.id}') - self.assertTrue(response.status_code in [202, 204]) + self.assertTrue(response.status_code in [status.HTTP_202_ACCEPTED, status.HTTP_204_NO_CONTENT]) def test_destroy_admin_override(self): response = self.client.delete(f'/api/library/{self.unowned.id}') - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.user.is_staff = True self.user.save() response = self.client.delete(f'/api/library/{self.unowned.id}') - self.assertTrue(response.status_code in [202, 204]) + self.assertTrue(response.status_code in [status.HTTP_202_ACCEPTED, status.HTTP_204_NO_CONTENT]) def test_claim(self): response = self.client.post(f'/api/library/{self.owned.id}/claim') - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.owned.is_common = True self.owned.save() response = self.client.post(f'/api/library/{self.owned.id}/claim') - self.assertEqual(response.status_code, 304) + self.assertEqual(response.status_code, status.HTTP_304_NOT_MODIFIED) response = self.client.post(f'/api/library/{self.unowned.id}/claim') - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) 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') - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) self.unowned.refresh_from_db() self.assertEqual(self.unowned.owner, self.user) self.assertEqual(self.unowned.owner, self.user) @@ -233,26 +234,26 @@ class TestLibraryViewset(APITestCase): def test_claim_anonymous(self): self.client.logout() response = self.client.post(f'/api/library/{self.owned.id}/claim') - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_retrieve_common(self): self.client.logout() response = self.client.get('/api/library/active') - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) 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') - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) 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.assertEqual(response.status_code, status.HTTP_200_OK) self.assertFalse(_response_contains(response, self.unowned)) user2 = User.objects.create(username='UserTest2') @@ -260,37 +261,37 @@ class TestLibraryViewset(APITestCase): Subscription.subscribe(user=user2, item=self.unowned) Subscription.subscribe(user=user2, item=self.owned) response = self.client.get('/api/library/active') - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(_response_contains(response, self.unowned)) self.assertEqual(len(response.data), 3) def test_subscriptions(self): response = self.client.delete(f'/api/library/{self.unowned.id}/unsubscribe') - self.assertEqual(response.status_code, 204) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertFalse(self.user in self.unowned.subscribers()) response = self.client.post(f'/api/library/{self.unowned.id}/subscribe') - self.assertEqual(response.status_code, 204) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertTrue(self.user in self.unowned.subscribers()) response = self.client.post(f'/api/library/{self.unowned.id}/subscribe') - self.assertEqual(response.status_code, 204) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertTrue(self.user in self.unowned.subscribers()) response = self.client.delete(f'/api/library/{self.unowned.id}/unsubscribe') - self.assertEqual(response.status_code, 204) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertFalse(self.user in self.unowned.subscribers()) def test_retrieve_templates(self): response = self.client.get('/api/library/templates') - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertFalse(_response_contains(response, self.common)) self.assertFalse(_response_contains(response, self.unowned)) self.assertFalse(_response_contains(response, self.owned)) LibraryTemplate.objects.create(lib_source=self.unowned) response = self.client.get('/api/library/templates') - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertFalse(_response_contains(response, self.common)) self.assertTrue(_response_contains(response, self.unowned)) self.assertFalse(_response_contains(response, self.owned)) @@ -312,13 +313,13 @@ class TestRSFormViewset(APITestCase): title='Test3' ) response = self.client.get('/api/rsforms') - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) 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') - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(_response_contains(response, non_schema)) self.assertTrue(_response_contains(response, self.unowned.item)) self.assertTrue(_response_contains(response, self.owned.item)) @@ -327,7 +328,7 @@ class TestRSFormViewset(APITestCase): 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') - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) def test_details(self): schema = RSForm.create(title='Test', owner=self.user) @@ -342,7 +343,7 @@ class TestRSFormViewset(APITestCase): response = self.client.get(f'/api/rsforms/{schema.item.id}/details') - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['title'], 'Test') self.assertEqual(len(response.data['items']), 2) self.assertEqual(response.data['items'][0]['id'], x1.id) @@ -362,7 +363,7 @@ class TestRSFormViewset(APITestCase): f'/api/rsforms/{schema.item.id}/check', data=data, format='json' ) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['parseResult'], True) self.assertEqual(response.data['syntax'], Syntax.MATH) self.assertEqual(response.data['astText'], '[=[X1][X1]]') @@ -373,7 +374,7 @@ class TestRSFormViewset(APITestCase): f'/api/rsforms/{self.unowned.item.id}/check', data=data, format='json' ) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) def test_resolve(self): schema = RSForm.create(title='Test') @@ -385,7 +386,7 @@ class TestRSFormViewset(APITestCase): f'/api/rsforms/{schema.item.id}/resolve', data=data, format='json' ) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['input'], '@{1|редкий} @{X1|plur,datv}') self.assertEqual(response.data['output'], 'редким синим слонам') self.assertEqual(len(response.data['refs']), 2) @@ -411,7 +412,7 @@ class TestRSFormViewset(APITestCase): 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') - self.assertEqual(response.status_code, 201) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.data['owner'], self.user.pk) self.assertTrue(response.data['title'] != '') @@ -419,7 +420,7 @@ class TestRSFormViewset(APITestCase): schema = RSForm.create(title='Test') schema.insert_at(1, 'X1', CstType.BASE) response = self.client.get(f'/api/rsforms/{schema.item.id}/export-trs') - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.headers['Content-Disposition'], 'attachment; filename=Schema.trs') with io.BytesIO(response.content) as stream: with ZipFile(stream, 'r') as zipped_file: @@ -432,7 +433,7 @@ class TestRSFormViewset(APITestCase): f'/api/rsforms/{self.unowned.item.id}/cst-create', data=data, format='json' ) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) item = self.owned.item Constituenta.objects.create( @@ -451,7 +452,7 @@ class TestRSFormViewset(APITestCase): f'/api/rsforms/{item.id}/cst-create', data=data, format='json' ) - self.assertEqual(response.status_code, 201) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.data['new_cst']['alias'], 'X3') x3 = Constituenta.objects.get(alias=response.data['new_cst']['alias']) self.assertEqual(x3.order, 3) @@ -467,7 +468,7 @@ class TestRSFormViewset(APITestCase): f'/api/rsforms/{item.id}/cst-create', data=data, format='json' ) - self.assertEqual(response.status_code, 201) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.data['new_cst']['alias'], data['alias']) x4 = Constituenta.objects.get(alias=response.data['new_cst']['alias']) self.assertEqual(x4.order, 3) @@ -503,27 +504,27 @@ class TestRSFormViewset(APITestCase): f'/api/rsforms/{self.unowned.item.id}/cst-rename', data=data, format='json' ) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) response = self.client.patch( f'/api/rsforms/{self.owned.item.id}/cst-rename', data=data, format='json' ) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) data = {'id': cst1.pk, 'alias': cst1.alias, 'cst_type': 'term'} response = self.client.patch( f'/api/rsforms/{self.owned.item.id}/cst-rename', data=data, format='json' ) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) data = {'id': cst1.pk, 'alias': cst3.alias} response = self.client.patch( f'/api/rsforms/{self.owned.item.id}/cst-rename', data=data, format='json' ) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) data = {'alias': 'D2', 'cst_type': 'term', 'id': cst1.pk} item = self.owned.item @@ -540,7 +541,7 @@ class TestRSFormViewset(APITestCase): f'/api/rsforms/{item.id}/cst-rename', data=data, format='json' ) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['new_cst']['alias'], 'D2') self.assertEqual(response.data['new_cst']['cst_type'], 'term') d1.refresh_from_db() @@ -578,27 +579,27 @@ class TestRSFormViewset(APITestCase): f'/api/rsforms/{self.unowned.item.id}/cst-substitute', data=data, format='json' ) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) response = self.client.patch( f'/api/rsforms/{self.owned.item.id}/cst-substitute', data=data, format='json' ) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) data = {'original': unowned.pk, 'substitution': x1.pk, 'transfer_term': True} response = self.client.patch( f'/api/rsforms/{self.owned.item.id}/cst-substitute', data=data, format='json' ) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) data = {'original': x1.pk, 'substitution': x1.pk, 'transfer_term': True} response = self.client.patch( f'/api/rsforms/{self.owned.item.id}/cst-substitute', data=data, format='json' ) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) d1 = Constituenta.objects.create( alias='D1', @@ -612,7 +613,7 @@ class TestRSFormViewset(APITestCase): f'/api/rsforms/{self.owned.item.id}/cst-substitute', data=data, format='json' ) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) d1.refresh_from_db() x2.refresh_from_db() @@ -634,7 +635,7 @@ class TestRSFormViewset(APITestCase): f'/api/rsforms/{item.id}/cst-create', data=data, format='json' ) - self.assertEqual(response.status_code, 201) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.data['new_cst']['alias'], 'X3') self.assertEqual(response.data['new_cst']['cst_type'], 'basic') self.assertEqual(response.data['new_cst']['convention'], '1') @@ -651,7 +652,7 @@ class TestRSFormViewset(APITestCase): f'/api/rsforms/{schema.item.id}/cst-delete-multiple', data=data, format='json' ) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 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) @@ -662,7 +663,7 @@ class TestRSFormViewset(APITestCase): ) x2.refresh_from_db() schema.item.refresh_from_db() - self.assertEqual(response.status_code, 202) + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) self.assertEqual(len(response.data['items']), 1) self.assertEqual(schema.constituents().count(), 1) self.assertEqual(x2.alias, 'X2') @@ -674,7 +675,7 @@ class TestRSFormViewset(APITestCase): f'/api/rsforms/{schema.item.id}/cst-delete-multiple', data=data, format='json' ) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) def test_move_constituenta(self): item = self.owned.item @@ -683,7 +684,7 @@ class TestRSFormViewset(APITestCase): f'/api/rsforms/{item.id}/cst-moveto', data=data, format='json' ) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 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) @@ -694,7 +695,7 @@ class TestRSFormViewset(APITestCase): ) x1.refresh_from_db() x2.refresh_from_db() - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['id'], item.id) self.assertEqual(x1.order, 2) self.assertEqual(x2.order, 1) @@ -705,12 +706,12 @@ class TestRSFormViewset(APITestCase): f'/api/rsforms/{item.id}/cst-moveto', data=data, format='json' ) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) def test_reset_aliases(self): item = self.owned.item response = self.client.patch(f'/api/rsforms/{item.id}/reset-aliases') - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['id'], item.id) x2 = Constituenta.objects.create(schema=item, alias='X2', cst_type='basic', order=1) @@ -720,7 +721,7 @@ class TestRSFormViewset(APITestCase): x1.refresh_from_db() x2.refresh_from_db() d11.refresh_from_db() - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(x2.order, 1) self.assertEqual(x2.alias, 'X1') self.assertEqual(x1.order, 2) @@ -729,7 +730,7 @@ class TestRSFormViewset(APITestCase): self.assertEqual(d11.alias, 'D1') response = self.client.patch(f'/api/rsforms/{item.id}/reset-aliases') - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) def test_load_trs(self): schema = self.owned @@ -744,7 +745,7 @@ class TestRSFormViewset(APITestCase): data=data, format='multipart' ) schema.item.refresh_from_db() - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(schema.item.title, 'Test11') self.assertEqual(len(response.data['items']), 25) self.assertEqual(schema.constituents().count(), 25) @@ -769,7 +770,7 @@ class TestRSFormViewset(APITestCase): data=data, format='json' ) - self.assertEqual(response.status_code, 201) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.data['title'], 'Title') self.assertEqual(response.data['items'][0]['alias'], x1.alias) self.assertEqual(response.data['items'][0]['term_raw'], x1.term_raw) @@ -778,6 +779,55 @@ class TestRSFormViewset(APITestCase): self.assertEqual(response.data['items'][1]['term_resolved'], d1.term_resolved) +class TestVersionViews(APITestCase): + ''' Testing versioning endpoints. ''' + def setUp(self): + self.factory = APIRequestFactory() + self.user = User.objects.create(username='UserTest') + self.client = APIClient() + self.client.force_authenticate(user=self.user) + self.owned = RSForm.create(title='Test', alias='T1', owner=self.user) + self.unowned = RSForm.create(title='Test2', alias='T2') + self.x1 = Constituenta.objects.create( + schema=self.owned.item, + alias='X1', + cst_type='basic', + convention='testStart', + order=1 + ) + + def test_create_version(self): + invalid_data = {'description': 'test'} + data = {'version': '1.0.0', 'description': 'test'} + invalid_id = 1338 + response = self.client.post( + f'/api/rsforms/{invalid_id}/versions/create', + data=data, format='json' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + response = self.client.post( + f'/api/rsforms/{self.unowned.item.id}/versions/create', + data=data, format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + response = self.client.post( + f'/api/rsforms/{self.owned.item.id}/versions/create', + data=invalid_data, format='json' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + response = self.client.post( + f'/api/rsforms/{self.owned.item.id}/versions/create', + data=data, format='json' + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertTrue('version' in response.data) + self.assertTrue('schema' in response.data) + self.assertTrue(response.data['version'] in [v['id'] for v in response.data['schema']['versions']]) + + class TestRSLanguageViews(APITestCase): def setUp(self): self.factory = APIRequestFactory() @@ -793,7 +843,7 @@ class TestRSLanguageViews(APITestCase): '/api/rsforms/create-detailed', data=data, format='multipart' ) - self.assertEqual(response.status_code, 201) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.data['owner'], self.user.pk) self.assertEqual(response.data['title'], 'Test123') self.assertEqual(response.data['alias'], 'ks1') @@ -805,7 +855,7 @@ class TestRSLanguageViews(APITestCase): '/api/rsforms/create-detailed', data=data, format='json' ) - self.assertEqual(response.status_code, 201) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.data['owner'], self.user.pk) self.assertEqual(response.data['title'], 'Test123') self.assertEqual(response.data['alias'], 'ks1') @@ -818,7 +868,7 @@ class TestRSLanguageViews(APITestCase): data=data, format='json' ) response = convert_to_ascii(request) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['result'], r'1 \eq 1') def test_convert_to_ascii_missing_data(self): @@ -828,7 +878,7 @@ class TestRSLanguageViews(APITestCase): data=data, format='json' ) response = convert_to_ascii(request) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIsInstance(response.data['expression'][0], ErrorDetail) def test_convert_to_math(self): @@ -838,7 +888,7 @@ class TestRSLanguageViews(APITestCase): data=data, format='json' ) response = convert_to_math(request) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['result'], r'1=1') def test_convert_to_math_missing_data(self): @@ -848,7 +898,7 @@ class TestRSLanguageViews(APITestCase): data=data, format='json' ) response = convert_to_math(request) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIsInstance(response.data['expression'][0], ErrorDetail) def test_parse_expression(self): @@ -858,7 +908,7 @@ class TestRSLanguageViews(APITestCase): data=data, format='json' ) response = parse_expression(request) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['parseResult'], True) self.assertEqual(response.data['syntax'], Syntax.MATH) self.assertEqual(response.data['astText'], '[=[1][1]]') @@ -870,7 +920,7 @@ class TestRSLanguageViews(APITestCase): data=data, format='json' ) response = parse_expression(request) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIsInstance(response.data['expression'][0], ErrorDetail) @@ -889,7 +939,7 @@ class TestNaturalLanguageViews(APITestCase): data=data, format='json' ) response = parse_text(request) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) self._assert_tags(response.data['result'], 'datv,NOUN,plur,anim,masc') def test_inflect(self): @@ -899,7 +949,7 @@ class TestNaturalLanguageViews(APITestCase): data=data, format='json' ) response = inflect(request) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['result'], 'синим слонам') def test_generate_lexeme(self): @@ -909,6 +959,6 @@ class TestNaturalLanguageViews(APITestCase): data=data, format='json' ) response = generate_lexeme(request) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data['items']), 12) self.assertEqual(response.data['items'][0]['text'], 'синий слон') diff --git a/rsconcept/backend/apps/rsform/urls.py b/rsconcept/backend/apps/rsform/urls.py index 71640190..cee5d6b8 100644 --- a/rsconcept/backend/apps/rsform/urls.py +++ b/rsconcept/backend/apps/rsform/urls.py @@ -13,6 +13,7 @@ urlpatterns = [ 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('rsforms//versions/create', views.create_version), path('rslang/parse-expression', views.parse_expression), path('rslang/to-ascii', views.convert_to_ascii), diff --git a/rsconcept/backend/apps/rsform/views.py b/rsconcept/backend/apps/rsform/views.py index b684975e..038eb13c 100644 --- a/rsconcept/backend/apps/rsform/views.py +++ b/rsconcept/backend/apps/rsform/views.py @@ -1,14 +1,14 @@ ''' REST API: RSForms for conceptual schemas. ''' import json -from typing import cast +from typing import cast, Union from django.db import transaction from django.http import HttpResponse from django_filters.rest_framework import DjangoFilterBackend from django.db.models import Q from rest_framework import views, viewsets, filters, generics, permissions -from rest_framework.decorators import action +from rest_framework.decorators import action, api_view, permission_classes from rest_framework.response import Response -from rest_framework.decorators import api_view +from rest_framework.request import Request from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import status as c @@ -27,14 +27,14 @@ class LibraryActiveView(generics.ListAPIView): serializer_class = s.LibraryItemSerializer def get_queryset(self): - user = self.request.user - if not user.is_anonymous: + if self.request.user.is_anonymous: + return m.LibraryItem.objects.filter(is_common=True).order_by('-time_update') + else: + user = cast(m.User, self.request.user) # pylint: disable=unsupported-binary-operation return m.LibraryItem.objects.filter( Q(is_common=True) | Q(owner=user) | Q(subscription__user=user) ).distinct().order_by('-time_update') - else: - return m.LibraryItem.objects.filter(is_common=True).order_by('-time_update') @extend_schema(tags=['Library']) @@ -86,14 +86,14 @@ class LibraryViewSet(viewsets.ModelViewSet): def get_permissions(self): if self.action in ['update', 'destroy', 'partial_update']: - permission_classes = [utils.ObjectOwnerOrAdmin] + permission_list = [utils.ObjectOwnerOrAdmin] elif self.action in ['create', 'clone', 'subscribe', 'unsubscribe']: - permission_classes = [permissions.IsAuthenticated] + permission_list = [permissions.IsAuthenticated] elif self.action in ['claim']: - permission_classes = [utils.IsClaimable] + permission_list = [utils.IsClaimable] else: - permission_classes = [permissions.AllowAny] - return [permission() for permission in permission_classes] + permission_list = [permissions.AllowAny] + return [permission() for permission in permission_list] def _get_item(self) -> m.LibraryItem: return cast(m.LibraryItem, self.get_object()) @@ -109,7 +109,7 @@ class LibraryViewSet(viewsets.ModelViewSet): ) @transaction.atomic @action(detail=True, methods=['post'], url_path='clone') - def clone(self, request, pk): + def clone(self, request: Request, pk): ''' Endpoint: Create deep copy of library item. ''' serializer = s.LibraryItemSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -141,13 +141,13 @@ class LibraryViewSet(viewsets.ModelViewSet): ) @transaction.atomic @action(detail=True, methods=['post']) - def claim(self, request, pk=None): + def claim(self, request: Request, pk=None): ''' Endpoint: Claim ownership of LibraryItem. ''' item = self._get_item() if item.owner == self.request.user: - return Response(status=304) + return Response(status=c.HTTP_304_NOT_MODIFIED) else: - item.owner = self.request.user + item.owner = cast(m.User, self.request.user) item.save() m.Subscription.subscribe(user=item.owner, item=item) return Response( @@ -162,10 +162,10 @@ class LibraryViewSet(viewsets.ModelViewSet): responses={c.HTTP_204_NO_CONTENT: None} ) @action(detail=True, methods=['post']) - def subscribe(self, request, pk): + def subscribe(self, request: Request, pk): ''' Endpoint: Subscribe current user to item. ''' item = self._get_item() - m.Subscription.subscribe(user=self.request.user, item=item) + m.Subscription.subscribe(user=cast(m.User, self.request.user), item=item) return Response(status=c.HTTP_204_NO_CONTENT) @extend_schema( @@ -175,10 +175,10 @@ class LibraryViewSet(viewsets.ModelViewSet): responses={c.HTTP_204_NO_CONTENT: None}, ) @action(detail=True, methods=['delete']) - def unsubscribe(self, request, pk): + def unsubscribe(self, request: Request, pk): ''' Endpoint: Unsubscribe current user from item. ''' item = self._get_item() - m.Subscription.unsubscribe(user=self.request.user, item=item) + m.Subscription.unsubscribe(user=cast(m.User, self.request.user), item=item) return Response(status=c.HTTP_204_NO_CONTENT) @@ -190,7 +190,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr serializer_class = s.LibraryItemSerializer def _get_schema(self) -> m.RSForm: - return m.RSForm(self.get_object()) # type: ignore + return m.RSForm(cast(m.LibraryItem, self.get_object())) def get_permissions(self): ''' Determine permission class. ''' @@ -208,7 +208,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr responses={c.HTTP_201_CREATED: s.NewCstResponse} ) @action(detail=True, methods=['post'], url_path='cst-create') - def cst_create(self, request, pk): + def cst_create(self, request: Request, pk): ''' Create new constituenta. ''' schema = self._get_schema() serializer = s.CstCreateSerializer(data=request.data) @@ -237,7 +237,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr ) @transaction.atomic @action(detail=True, methods=['patch'], url_path='cst-rename') - def cst_rename(self, request, pk): + def cst_rename(self, request: Request, pk): ''' Rename constituenta possibly changing type. ''' schema = self._get_schema() serializer = s.CstRenameSerializer(data=request.data, context={'schema': schema}) @@ -264,7 +264,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr ) @transaction.atomic @action(detail=True, methods=['patch'], url_path='cst-substitute') - def cst_substitute(self, request, pk): + def cst_substitute(self, request: Request, pk): ''' Substitute occurrences of constituenta with another one. ''' schema = self._get_schema() serializer = s.CstSubstituteSerializer(data=request.data, context={'schema': schema}) @@ -287,7 +287,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr responses={c.HTTP_202_ACCEPTED: s.RSFormParseSerializer} ) @action(detail=True, methods=['patch'], url_path='cst-delete-multiple') - def cst_delete_multiple(self, request, pk): + def cst_delete_multiple(self, request: Request, pk): ''' Endpoint: Delete multiple constituents. ''' schema = self._get_schema() serializer = s.CstListSerializer( @@ -309,7 +309,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr responses={c.HTTP_200_OK: s.RSFormParseSerializer} ) @action(detail=True, methods=['patch'], url_path='cst-moveto') - def cst_moveto(self, request, pk): + def cst_moveto(self, request: Request, pk): ''' Endpoint: Move multiple constituents. ''' schema = self._get_schema() serializer = s.CstMoveSerializer( @@ -334,7 +334,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr responses={c.HTTP_200_OK: s.RSFormParseSerializer} ) @action(detail=True, methods=['patch'], url_path='reset-aliases') - def reset_aliases(self, request, pk): + def reset_aliases(self, request: Request, pk): ''' Endpoint: Recreate all aliases based on order. ''' schema = self._get_schema() schema.reset_aliases() @@ -350,12 +350,12 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr responses={c.HTTP_200_OK: s.RSFormParseSerializer} ) @action(detail=True, methods=['patch'], url_path='load-trs') - def load_trs(self, request, pk): + def load_trs(self, request: Request, pk): ''' Endpoint: Load data from file and replace current schema. ''' - serializer = s.RSFormUploadSerializer(data=request.data) - serializer.is_valid(raise_exception=True) + input_serializer = s.RSFormUploadSerializer(data=request.data) + input_serializer.is_valid(raise_exception=True) schema = self._get_schema() - load_metadata = serializer.validated_data['load_metadata'] + load_metadata = input_serializer.validated_data['load_metadata'] data = utils.read_trs(request.FILES['file'].file) data['id'] = schema.item.pk @@ -364,10 +364,10 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr context={'load_meta': load_metadata} ) serializer.is_valid(raise_exception=True) - schema = serializer.save() + result = serializer.save() return Response( status=c.HTTP_200_OK, - data=s.RSFormParseSerializer(schema.item).data + data=s.RSFormParseSerializer(result.item).data ) @extend_schema( @@ -377,7 +377,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr responses={c.HTTP_200_OK: s.RSFormSerializer} ) @action(detail=True, methods=['get']) - def contents(self, request, pk): + def contents(self, request: Request, pk): ''' Endpoint: View schema db contents (including constituents). ''' schema = s.RSFormSerializer(self.get_object()) return Response( @@ -392,10 +392,9 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr responses={c.HTTP_200_OK: s.RSFormParseSerializer} ) @action(detail=True, methods=['get']) - def details(self, request, pk): + def details(self, request: Request, pk): ''' Endpoint: Detailed schema view including statuses and parse. ''' - schema = self._get_schema() - serializer = s.RSFormParseSerializer(schema.item) + serializer = s.RSFormParseSerializer(cast(m.LibraryItem, self.get_object())) return Response( status=c.HTTP_200_OK, data=serializer.data @@ -408,7 +407,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr responses={c.HTTP_200_OK: s.ExpressionParseSerializer}, ) @action(detail=True, methods=['post']) - def check(self, request, pk): + def check(self, request: Request, pk): ''' Endpoint: Check RSLang expression against schema context. ''' serializer = s.ExpressionSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -427,7 +426,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr responses={c.HTTP_200_OK: s.ResolverSerializer} ) @action(detail=True, methods=['post']) - def resolve(self, request, pk): + def resolve(self, request: Request, pk): ''' Endpoint: Resolve references in text against schema terms context. ''' serializer = s.TextSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -446,7 +445,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr responses={(c.HTTP_200_OK, 'application/zip'): bytes} ) @action(detail=True, methods=['get'], url_path='export-trs') - def export_trs(self, request, pk): + def export_trs(self, request: Request, pk): ''' Endpoint: Download Exteor compatible file. ''' schema = s.RSFormTRSSerializer(self._get_schema()).data trs = utils.write_trs(schema) @@ -465,6 +464,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr class TrsImportView(views.APIView): ''' Endpoint: Upload RS form in Exteor format. ''' serializer_class = s.FileSerializer + permission_classes = [permissions.IsAuthenticated] @extend_schema( summary='import TRS file into RSForm', @@ -472,11 +472,9 @@ class TrsImportView(views.APIView): request=s.FileSerializer, responses={c.HTTP_201_CREATED: s.LibraryItemSerializer} ) - def post(self, request): + def post(self, request: Request): data = utils.read_trs(request.FILES['file'].file) - owner = self.request.user - if owner.is_anonymous: - owner = None + owner = cast(m.User, self.request.user) _prepare_rsform_data(data, request, owner) serializer = s.RSFormTRSSerializer( data=data, @@ -498,11 +496,9 @@ class TrsImportView(views.APIView): responses={c.HTTP_201_CREATED: s.LibraryItemSerializer} ) @api_view(['POST']) -def create_rsform(request): +def create_rsform(request: Request): ''' Endpoint: Create RSForm from user input and/or trs file. ''' - owner = request.user - if owner.is_anonymous: - owner = None + owner = cast(m.User, request.user) if not request.user.is_anonymous else None if 'file' not in request.FILES: serializer = s.LibraryItemSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -517,16 +513,17 @@ def create_rsform(request): else: data = utils.read_trs(request.FILES['file'].file) _prepare_rsform_data(data, request, owner) - serializer = s.RSFormTRSSerializer(data=data, context={'load_meta': True}) - serializer.is_valid(raise_exception=True) - schema = serializer.save() + serializer_rsform = s.RSFormTRSSerializer(data=data, context={'load_meta': True}) + serializer_rsform.is_valid(raise_exception=True) + schema = serializer_rsform.save() result = s.LibraryItemSerializer(schema.item) return Response( status=c.HTTP_201_CREATED, data=result.data ) -def _prepare_rsform_data(data: dict, request, owner: m.User): + +def _prepare_rsform_data(data: dict, request: Request, owner: Union[m.User, None]): data['owner'] = owner if 'title' in request.data and request.data['title'] != '': data['title'] = request.data['title'] @@ -548,6 +545,45 @@ def _prepare_rsform_data(data: dict, request, owner: m.User): data['is_canonical'] = is_canonical +@extend_schema( + summary='save version for RSForm copying current content', + tags=['Versions'], + request=s.VersionCreateSerializer, + responses={ + c.HTTP_201_CREATED: s.RSFormParseSerializer, + c.HTTP_403_FORBIDDEN: None, + c.HTTP_404_NOT_FOUND: None + } +) +@api_view(['POST']) +@permission_classes([permissions.IsAuthenticated]) +def create_version(request: Request, pk_item: int): + ''' Endpoint: Create new version for RSForm copying current content. ''' + try: + item = m.LibraryItem.objects.get(pk=pk_item) + except m.LibraryItem.DoesNotExist: + return Response(status=c.HTTP_404_NOT_FOUND) + creator = request.user + if not creator.is_staff and creator != item.owner: + return Response(status=c.HTTP_403_FORBIDDEN) + + version_input = s.VersionCreateSerializer(data=request.data) + version_input.is_valid(raise_exception=True) + data = s.RSFormSerializer(item).to_versioned_data() + result = m.RSForm(item).create_version( + version=version_input.validated_data['version'], + description=version_input.validated_data['description'], + data=data + ) + return Response( + status=c.HTTP_201_CREATED, + data={ + 'version': result.pk, + 'schema': s.RSFormParseSerializer(item).data + } + ) + + @extend_schema( summary='RS expression into Syntax Tree', tags=['FormalLanguage'], @@ -556,7 +592,7 @@ def _prepare_rsform_data(data: dict, request, owner: m.User): auth=None ) @api_view(['POST']) -def parse_expression(request): +def parse_expression(request: Request): ''' Endpoint: Parse RS expression. ''' serializer = s.ExpressionSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -576,7 +612,7 @@ def parse_expression(request): auth=None ) @api_view(['POST']) -def convert_to_ascii(request): +def convert_to_ascii(request: Request): ''' Endpoint: Convert expression to ASCII syntax. ''' serializer = s.ExpressionSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -596,7 +632,7 @@ def convert_to_ascii(request): auth=None ) @api_view(['POST']) -def convert_to_math(request): +def convert_to_math(request: Request): ''' Endpoint: Convert expression to MATH syntax. ''' serializer = s.ExpressionSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -615,7 +651,7 @@ def convert_to_math(request): auth=None ) @api_view(['POST']) -def inflect(request): +def inflect(request: Request): ''' Endpoint: Generate wordform with set grammemes. ''' serializer = s.WordFormSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -636,7 +672,7 @@ def inflect(request): auth=None ) @api_view(['POST']) -def generate_lexeme(request): +def generate_lexeme(request: Request): ''' Endpoint: Generate complete set of wordforms for lexeme. ''' serializer = s.TextSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -656,7 +692,7 @@ def generate_lexeme(request): auth=None ) @api_view(['POST']) -def parse_text(request): +def parse_text(request: Request): ''' Endpoint: Get likely vocabulary parse. ''' serializer = s.TextSerializer(data=request.data) serializer.is_valid(raise_exception=True) diff --git a/rsconcept/backend/cctext/ruparser.py b/rsconcept/backend/cctext/ruparser.py index 3eff35e2..f0d8d64c 100644 --- a/rsconcept/backend/cctext/ruparser.py +++ b/rsconcept/backend/cctext/ruparser.py @@ -394,8 +394,7 @@ class PhraseParser: def _filtered_parse(text: str): capital = Capitalization.from_text(text) score_filter = PhraseParser._filter_score(morpho.parse(text)) - for form in PhraseParser._filter_capital(score_filter, capital): - yield form + yield from PhraseParser._filter_capital(score_filter, capital) @staticmethod def _filter_score(generator): @@ -412,8 +411,7 @@ class PhraseParser: continue yield form else: - for form in generator: - yield form + yield from generator @staticmethod def _parse_word(text: str, require_index: int = INDEX_NONE, diff --git a/rsconcept/backend/project/settings.py b/rsconcept/backend/project/settings.py index 45c9ed46..b369e42f 100644 --- a/rsconcept/backend/project/settings.py +++ b/rsconcept/backend/project/settings.py @@ -10,6 +10,8 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/4.1/ref/settings/ ''' +import sys +import logging import os from pathlib import Path @@ -229,6 +231,10 @@ LOGGING = { }, 'root': { 'handlers': ['console'], - 'level': 'DEBUG', + 'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO') }, } + + +if len(sys.argv) > 1 and sys.argv[1] == 'test': + logging.disable(logging.CRITICAL) diff --git a/rsconcept/frontend/src/models/library.ts b/rsconcept/frontend/src/models/library.ts index 25462d82..c393bbcb 100644 --- a/rsconcept/frontend/src/models/library.ts +++ b/rsconcept/frontend/src/models/library.ts @@ -86,6 +86,16 @@ export enum LibraryItemType { OPERATIONS_SCHEMA = 'oss' } +/** + * Represents library item version information. + */ +export interface IVersionInfo { + id: number; + version: string; + description: string; + time_create: string; +} + /** * Represents library item common data typical for all item types. */ @@ -107,6 +117,8 @@ export interface ILibraryItem { */ export interface ILibraryItemEx extends ILibraryItem { subscribers: number[]; + version?: number; + versions: IVersionInfo[]; } /**