diff --git a/rsconcept/backend/apps/rsform/admin.py b/rsconcept/backend/apps/rsform/admin.py index c5dbafd1..c8e238c6 100644 --- a/rsconcept/backend/apps/rsform/admin.py +++ b/rsconcept/backend/apps/rsform/admin.py @@ -10,7 +10,8 @@ class ConstituentaAdmin(admin.ModelAdmin): list_display = ['schema', 'alias', 'term_resolved', 'definition_resolved'] search_fields = ['term_resolved', 'definition_resolved'] -class LibraryAdmin(admin.ModelAdmin): + +class LibraryItemAdmin(admin.ModelAdmin): ''' Admin model: LibraryItem. ''' date_hierarchy = 'time_update' list_display = [ @@ -22,6 +23,18 @@ class LibraryAdmin(admin.ModelAdmin): search_fields = ['alias', 'title'] +class LibraryTemplateAdmin(admin.ModelAdmin): + ''' Admin model: LibraryTemplate. ''' + list_display = ['id', 'alias'] + list_select_related = ['lib_source'] + + def alias(self, template: models.LibraryTemplate): + if template.lib_source: + return template.lib_source.alias + else: + return 'N/A' + + class SubscriptionAdmin(admin.ModelAdmin): ''' Admin model: Subscriptions. ''' list_display = ['id', 'item', 'user'] @@ -32,5 +45,6 @@ class SubscriptionAdmin(admin.ModelAdmin): admin.site.register(models.Constituenta, ConstituentaAdmin) -admin.site.register(models.LibraryItem, LibraryAdmin) +admin.site.register(models.LibraryItem, LibraryItemAdmin) +admin.site.register(models.LibraryTemplate, LibraryTemplateAdmin) admin.site.register(models.Subscription, SubscriptionAdmin) diff --git a/rsconcept/backend/apps/rsform/migrations/0002_librarytemplate.py b/rsconcept/backend/apps/rsform/migrations/0002_librarytemplate.py new file mode 100644 index 00000000..00bc2699 --- /dev/null +++ b/rsconcept/backend/apps/rsform/migrations/0002_librarytemplate.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.6 on 2023-10-18 16:12 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('rsform', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='LibraryTemplate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('lib_source', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='rsform.libraryitem', verbose_name='Источник')), + ], + options={ + 'verbose_name': 'Шаблон', + 'verbose_name_plural': 'Шаблоны', + }, + ), + ] diff --git a/rsconcept/backend/apps/rsform/models.py b/rsconcept/backend/apps/rsform/models.py index 32b4957a..2a63df61 100644 --- a/rsconcept/backend/apps/rsform/models.py +++ b/rsconcept/backend/apps/rsform/models.py @@ -71,8 +71,7 @@ def _get_type_prefix(cst_type: CstType) -> str: class LibraryItem(Model): - ''' Abstract library item. - Please use wrappers below to access functionality. ''' + ''' Abstract library item.''' item_type: CharField = CharField( verbose_name='Тип', max_length=50, @@ -136,6 +135,21 @@ class LibraryItem(Model): Subscription.subscribe(user=self.owner, item=self) +class LibraryTemplate(Model): + ''' Template for library items and constituents. ''' + lib_source: ForeignKey = ForeignKey( + verbose_name='Источник', + to=LibraryItem, + on_delete=CASCADE, + null=True + ) + + class Meta: + ''' Model metadata. ''' + verbose_name = 'Шаблон' + verbose_name_plural = 'Шаблоны' + + class Subscription(Model): ''' User subscription to library item. ''' user: ForeignKey = ForeignKey( @@ -319,6 +333,8 @@ class RSForm: ''' Insert new constituenta at given position. All following constituents order is shifted by 1 position ''' if position <= 0: raise ValidationError('Invalid position: should be positive integer') + if self.constituents().filter(alias=alias).exists(): + raise ValidationError(f'Alias taken {alias}') currentSize = self.constituents().count() position = max(1, min(position, currentSize + 1)) update_list = \ @@ -342,6 +358,8 @@ class RSForm: @transaction.atomic def insert_last(self, alias: str, insert_type: CstType) -> 'Constituenta': ''' Insert new constituenta at last position ''' + if self.constituents().filter(alias=alias).exists(): + raise ValidationError(f'Alias taken {alias}') position = 1 if self.constituents().exists(): position += self.constituents().count() diff --git a/rsconcept/backend/apps/rsform/serializers.py b/rsconcept/backend/apps/rsform/serializers.py index c6204ec0..6a786e7a 100644 --- a/rsconcept/backend/apps/rsform/serializers.py +++ b/rsconcept/backend/apps/rsform/serializers.py @@ -188,13 +188,18 @@ class CstRenameSerializer(serializers.ModelSerializer): def validate(self, attrs): schema = cast(RSForm, self.context['schema']) old_cst = Constituenta.objects.get(pk=self.initial_data['id']) + new_alias = self.initial_data['alias'] if old_cst.schema != schema.item: raise serializers.ValidationError({ 'id': f'Изменяемая конституента должна относиться к изменяемой схеме: {schema.item.title}' }) - if old_cst.alias == self.initial_data['alias']: + if old_cst.alias == new_alias: raise serializers.ValidationError({ - 'alias': f'Имя конституенты должно отличаться от текущего: {self.initial_data["alias"]}' + 'alias': f'Имя конституенты должно отличаться от текущего: {new_alias}' + }) + if schema.constituents().filter(alias=new_alias).exists(): + raise serializers.ValidationError({ + 'alias': f'Конституента с таким именем уже существует: {new_alias}' }) self.instance = old_cst attrs['schema'] = schema.item diff --git a/rsconcept/backend/apps/rsform/tests/t_models.py b/rsconcept/backend/apps/rsform/tests/t_models.py index 43834ce1..838d673c 100644 --- a/rsconcept/backend/apps/rsform/tests/t_models.py +++ b/rsconcept/backend/apps/rsform/tests/t_models.py @@ -190,9 +190,17 @@ class TestRSForm(TestCase): self.assertEqual(cst2.order, 1) self.assertEqual(cst1.order, 2) + def test_insert_at_invalid_position(self): + schema = RSForm.create(title='Test') with self.assertRaises(ValidationError): schema.insert_at(0, 'X5', CstType.BASE) + def test_insert_at_invalid_alias(self): + schema = RSForm.create(title='Test') + schema.insert_at(1, 'X1', CstType.BASE) + with self.assertRaises(ValidationError): + schema.insert_at(2, 'X1', CstType.BASE) + def test_insert_at_reorder(self): schema = RSForm.create(title='Test') schema.insert_at(1, 'X1', CstType.BASE) diff --git a/rsconcept/backend/apps/rsform/tests/t_views.py b/rsconcept/backend/apps/rsform/tests/t_views.py index 38218cd2..382557b3 100644 --- a/rsconcept/backend/apps/rsform/tests/t_views.py +++ b/rsconcept/backend/apps/rsform/tests/t_views.py @@ -8,7 +8,10 @@ from rest_framework.exceptions import ErrorDetail from cctext import ReferenceType, split_grams from apps.users.models import User -from apps.rsform.models import Syntax, RSForm, Constituenta, CstType, LibraryItem, LibraryItemType, Subscription +from apps.rsform.models import ( + Syntax, RSForm, Constituenta, CstType, + LibraryItem, LibraryItemType, Subscription, LibraryTemplate +) from apps.rsform.views import ( convert_to_ascii, convert_to_math, @@ -264,6 +267,20 @@ class TestLibraryViewset(APITestCase): self.assertEqual(response.status_code, 204) 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.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.assertFalse(_response_contains(response, self.common)) + self.assertTrue(_response_contains(response, self.unowned)) + self.assertFalse(_response_contains(response, self.owned)) + class TestRSFormViewset(APITestCase): ''' Testing RSForm view. ''' @@ -420,22 +437,22 @@ class TestRSFormViewset(APITestCase): self.assertEqual(x4.order, 3) def test_rename_constituenta(self): - self.cst1 = Constituenta.objects.create( + cst1 = Constituenta.objects.create( alias='X1', schema=self.owned.item, order=1, convention='Test', term_raw='Test1', term_resolved='Test1', term_forms=[{'text':'form1', 'tags':'sing,datv'}] ) - self.cst2 = Constituenta.objects.create( + cst2 = Constituenta.objects.create( alias='X2', schema=self.unowned.item, order=1, convention='Test1', term_raw='Test2', term_resolved='Test2' ) - self.cst3 = Constituenta.objects.create( + cst3 = Constituenta.objects.create( alias='X3', schema=self.owned.item, order=2, term_raw='Test3', term_resolved='Test3', definition_raw='Test1', definition_resolved='Test2' ) - data = {'alias': 'D2', 'cst_type': 'term', 'id': self.cst2.pk} + data = {'alias': 'D2', 'cst_type': 'term', 'id': cst2.pk} response = self.client.patch( f'/api/rsforms/{self.unowned.item.id}/cst-rename', data=data, format='json' @@ -448,14 +465,21 @@ class TestRSFormViewset(APITestCase): ) self.assertEqual(response.status_code, 400) - data = {'alias': self.cst1.alias, 'cst_type': 'term', 'id': self.cst1.pk} + data = {'alias': cst1.alias, 'cst_type': 'term', 'id': cst1.pk} response = self.client.patch( f'/api/rsforms/{self.owned.item.id}/cst-rename', data=data, format='json' ) self.assertEqual(response.status_code, 400) - data = {'alias': 'D2', 'cst_type': 'term', 'id': self.cst1.pk} + data = {'alias': cst3.alias, 'id': cst1.pk} + response = self.client.patch( + f'/api/rsforms/{self.owned.item.id}/cst-rename', + data=data, format='json' + ) + self.assertEqual(response.status_code, 400) + + data = {'alias': 'D2', 'cst_type': 'term', 'id': cst1.pk} item = self.owned.item d1 = Constituenta.objects.create(schema=item, alias='D1', cst_type='term', order=4) d1.term_raw = '@{X1|plur}' @@ -463,9 +487,9 @@ class TestRSFormViewset(APITestCase): d1.save() self.assertEqual(d1.order, 4) - self.assertEqual(self.cst1.order, 1) - self.assertEqual(self.cst1.alias, 'X1') - self.assertEqual(self.cst1.cst_type, CstType.BASE) + self.assertEqual(cst1.order, 1) + self.assertEqual(cst1.alias, 'X1') + self.assertEqual(cst1.cst_type, CstType.BASE) response = self.client.patch( f'/api/rsforms/{item.id}/cst-rename', data=data, format='json' @@ -474,13 +498,13 @@ class TestRSFormViewset(APITestCase): self.assertEqual(response.data['new_cst']['alias'], 'D2') self.assertEqual(response.data['new_cst']['cst_type'], 'term') d1.refresh_from_db() - self.cst1.refresh_from_db() + cst1.refresh_from_db() self.assertEqual(d1.order, 4) self.assertEqual(d1.term_resolved, '') self.assertEqual(d1.term_raw, '@{D2|plur}') - self.assertEqual(self.cst1.order, 1) - self.assertEqual(self.cst1.alias, 'D2') - self.assertEqual(self.cst1.cst_type, CstType.TERM) + self.assertEqual(cst1.order, 1) + self.assertEqual(cst1.alias, 'D2') + self.assertEqual(cst1.cst_type, CstType.TERM) def test_create_constituenta_data(self): data = { diff --git a/rsconcept/backend/apps/rsform/urls.py b/rsconcept/backend/apps/rsform/urls.py index bc4aac7a..71640190 100644 --- a/rsconcept/backend/apps/rsform/urls.py +++ b/rsconcept/backend/apps/rsform/urls.py @@ -9,6 +9,7 @@ library_router.register('rsforms', views.RSFormViewSet) urlpatterns = [ path('library/active', views.LibraryActiveView.as_view(), name='library'), + path('library/templates', views.LibraryTemplatesView.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), diff --git a/rsconcept/backend/apps/rsform/views.py b/rsconcept/backend/apps/rsform/views.py index f8527479..f8ef7dd4 100644 --- a/rsconcept/backend/apps/rsform/views.py +++ b/rsconcept/backend/apps/rsform/views.py @@ -22,7 +22,7 @@ from . import utils @extend_schema(tags=['Library']) @extend_schema_view() class LibraryActiveView(generics.ListAPIView): - ''' Endpoint: Get list of rsforms available for active user. ''' + ''' Endpoint: Get list of library items available for active user. ''' permission_classes = (permissions.AllowAny,) serializer_class = s.LibraryItemSerializer @@ -35,6 +35,18 @@ class LibraryActiveView(generics.ListAPIView): ).distinct().order_by('-time_update') else: return m.LibraryItem.objects.filter(is_common=True).order_by('-time_update') + + +@extend_schema(tags=['Library']) +@extend_schema_view() +class LibraryTemplatesView(generics.ListAPIView): + ''' Endpoint: Get list of templates. ''' + permission_classes = (permissions.AllowAny,) + serializer_class = s.LibraryItemSerializer + + def get_queryset(self): + template_ids = m.LibraryTemplate.objects.values_list('lib_source', flat=True) + return m.LibraryItem.objects.filter(pk__in=template_ids) @extend_schema(tags=['Constituenta'])