From dc0555076b5ac94b0d6290c072722ea776d57226 Mon Sep 17 00:00:00 2001 From: IRBorisov <8611739+IRBorisov@users.noreply.github.com> Date: Sun, 2 Jun 2024 23:41:46 +0300 Subject: [PATCH] Implementing locations and permissions pt1 --- rsconcept/backend/apps/rsform/admin.py | 16 +- rsconcept/backend/apps/rsform/messages.py | 8 + .../migrations/0007_location_and_flags.py | 65 ++++++ .../backend/apps/rsform/models/LibraryItem.py | 46 ++++- .../backend/apps/rsform/models/__init__.py | 9 +- rsconcept/backend/apps/rsform/permissions.py | 5 +- .../apps/rsform/serializers/__init__.py | 3 + .../backend/apps/rsform/serializers/basics.py | 29 +++ .../apps/rsform/serializers/data_access.py | 24 ++- .../apps/rsform/serializers/io_files.py | 18 +- .../apps/rsform/tests/EndpointTester.py | 7 + .../rsform/tests/s_models/t_LibraryItem.py | 50 ++++- .../apps/rsform/tests/s_views/t_library.py | 85 +++++++- .../apps/rsform/tests/s_views/t_rsforms.py | 27 ++- .../backend/apps/rsform/views/library.py | 76 ++++++- .../backend/apps/rsform/views/rsforms.py | 27 ++- .../frontend/src/app/ApplicationLayout.tsx | 6 +- rsconcept/frontend/src/app/Footer.tsx | 1 + rsconcept/frontend/src/app/Router.tsx | 4 +- rsconcept/frontend/src/app/backendAPI.ts | 20 +- .../frontend/src/components/DomainIcons.tsx | 104 ++++++++++ rsconcept/frontend/src/components/Icons.tsx | 12 +- .../src/components/info/BadgeHelp.tsx | 9 +- .../components/info/ConstituentaTooltip.tsx | 2 +- .../components/select/SelectAccessPolicy.tsx | 60 ++++++ .../select/SelectFilterStrategy.tsx | 93 --------- .../components/select/SelectGraphFilter.tsx | 25 +-- .../components/select/SelectLocationHead.tsx | 68 ++++++ .../src/components/select/SelectMatchMode.tsx | 16 +- .../frontend/src/components/ui/Dropdown.tsx | 2 +- .../src/components/ui/LabeledValue.tsx | 2 +- .../frontend/src/components/ui/Modal.tsx | 2 +- .../frontend/src/components/ui/SearchBar.tsx | 18 +- .../frontend/src/components/ui/SelectTree.tsx | 1 - .../frontend/src/context/LibraryContext.tsx | 37 ++-- .../frontend/src/context/RSFormContext.tsx | 194 +++++++++++------- .../src/dialogs/DlgChangeLocation.tsx | 62 ++++++ .../src/dialogs/DlgCloneLibraryItem.tsx | 87 ++++++-- rsconcept/frontend/src/models/library.ts | 50 ++++- .../frontend/src/models/libraryAPI.test.ts | 48 ++++- rsconcept/frontend/src/models/libraryAPI.ts | 28 +++ .../frontend/src/models/miscellaneous.ts | 22 +- .../frontend/src/models/miscellaneousAPI.ts | 16 +- rsconcept/frontend/src/models/rsform.ts | 12 +- rsconcept/frontend/src/models/user.ts | 4 +- .../frontend/src/pages/CreateRSFormPage.tsx | 147 ------------- .../pages/CreateRSFormPage/CreateItemPage.tsx | 16 ++ .../pages/CreateRSFormPage/FormCreateItem.tsx | 190 +++++++++++++++++ .../src/pages/CreateRSFormPage/index.tsx | 1 + .../src/pages/LibraryPage/ItemIcons.tsx | 30 --- .../src/pages/LibraryPage/LibraryPage.tsx | 89 ++++---- .../src/pages/LibraryPage/SearchPanel.tsx | 192 ++++++++++++----- .../src/pages/LibraryPage/ViewLibrary.tsx | 84 +++----- .../src/pages/ManualsPage/TopicsDropdown.tsx | 2 +- .../pages/ManualsPage/items/HelpVersions.tsx | 4 +- .../RSFormPage/EditorRSForm/AccessToolbar.tsx | 70 +++++++ .../EditorRSForm/EditorLibraryItem.tsx | 23 ++- .../RSFormPage/EditorRSForm/EditorRSForm.tsx | 7 +- .../RSFormPage/EditorRSForm/FormRSForm.tsx | 101 +++------ .../RSFormPage/EditorRSForm/RSFormToolbar.tsx | 14 +- .../EditorRSForm/VersionsToolbar.tsx | 40 ++++ .../RSFormPage/EditorRSList/EditorRSList.tsx | 2 +- .../src/pages/RSFormPage/RSEditContext.tsx | 62 +++++- .../frontend/src/pages/RSFormPage/RSTabs.tsx | 2 +- rsconcept/frontend/src/styling/layers.css | 40 ---- rsconcept/frontend/src/styling/setup.css | 5 +- rsconcept/frontend/src/utils/codemirror.ts | 2 +- rsconcept/frontend/src/utils/constants.ts | 11 +- rsconcept/frontend/src/utils/labels.ts | 70 ++++--- rsconcept/frontend/src/utils/utils.ts | 24 ++- rsconcept/frontend/tailwind.config.js | 11 + 71 files changed, 1884 insertions(+), 855 deletions(-) create mode 100644 rsconcept/backend/apps/rsform/migrations/0007_location_and_flags.py create mode 100644 rsconcept/frontend/src/components/DomainIcons.tsx create mode 100644 rsconcept/frontend/src/components/select/SelectAccessPolicy.tsx delete mode 100644 rsconcept/frontend/src/components/select/SelectFilterStrategy.tsx create mode 100644 rsconcept/frontend/src/components/select/SelectLocationHead.tsx create mode 100644 rsconcept/frontend/src/dialogs/DlgChangeLocation.tsx delete mode 100644 rsconcept/frontend/src/pages/CreateRSFormPage.tsx create mode 100644 rsconcept/frontend/src/pages/CreateRSFormPage/CreateItemPage.tsx create mode 100644 rsconcept/frontend/src/pages/CreateRSFormPage/FormCreateItem.tsx create mode 100644 rsconcept/frontend/src/pages/CreateRSFormPage/index.tsx delete mode 100644 rsconcept/frontend/src/pages/LibraryPage/ItemIcons.tsx create mode 100644 rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/AccessToolbar.tsx create mode 100644 rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/VersionsToolbar.tsx delete mode 100644 rsconcept/frontend/src/styling/layers.css diff --git a/rsconcept/backend/apps/rsform/admin.py b/rsconcept/backend/apps/rsform/admin.py index 21eb50c1..e99c50b1 100644 --- a/rsconcept/backend/apps/rsform/admin.py +++ b/rsconcept/backend/apps/rsform/admin.py @@ -16,11 +16,11 @@ class LibraryItemAdmin(admin.ModelAdmin): date_hierarchy = 'time_update' list_display = [ 'alias', 'title', 'owner', - 'is_common', 'is_canonical', + 'visible', 'read_only', 'access_policy', 'location', 'time_update' ] - list_filter = ['is_common', 'is_canonical', 'time_update'] - search_fields = ['alias', 'title'] + list_filter = ['visible', 'read_only', 'access_policy', 'location', 'time_update'] + search_fields = ['alias', 'title', 'location'] class LibraryTemplateAdmin(admin.ModelAdmin): @@ -44,6 +44,15 @@ class SubscriptionAdmin(admin.ModelAdmin): ] +class EditorAdmin(admin.ModelAdmin): + ''' Admin model: Editors. ''' + list_display = ['id', 'item', 'editor'] + search_fields = [ + 'item__title', 'item__alias', + 'editor__username', 'editor__first_name', 'editor__last_name' + ] + + class VersionAdmin(admin.ModelAdmin): ''' Admin model: Versions. ''' list_display = ['id', 'item', 'version', 'description', 'time_create'] @@ -57,3 +66,4 @@ admin.site.register(models.LibraryItem, LibraryItemAdmin) admin.site.register(models.LibraryTemplate, LibraryTemplateAdmin) admin.site.register(models.Subscription, SubscriptionAdmin) admin.site.register(models.Version, VersionAdmin) +admin.site.register(models.Editor, EditorAdmin) diff --git a/rsconcept/backend/apps/rsform/messages.py b/rsconcept/backend/apps/rsform/messages.py index 16e47b69..f40636b6 100644 --- a/rsconcept/backend/apps/rsform/messages.py +++ b/rsconcept/backend/apps/rsform/messages.py @@ -30,6 +30,14 @@ def aliasTaken(name: str): return f'Имя уже используется: {name}' +def invalidLocation(): + return f'Некорректная строка расположения' + + +def invalidEnum(value: str): + return f'Неподдерживаемое значение параметра: {value}' + + def pyconceptFailure(): return 'Invalid data response from pyconcept' diff --git a/rsconcept/backend/apps/rsform/migrations/0007_location_and_flags.py b/rsconcept/backend/apps/rsform/migrations/0007_location_and_flags.py new file mode 100644 index 00000000..9d17dd14 --- /dev/null +++ b/rsconcept/backend/apps/rsform/migrations/0007_location_and_flags.py @@ -0,0 +1,65 @@ +# Hand written migration 20240531 + +from django.db import migrations, models + +from .. import models as m + + +class Migration(migrations.Migration): + + dependencies = [ + ('rsform', '0006_editor'), + ] + + def calculate_location(apps, schema_editor): + LibraryItem = apps.get_model('rsform', 'LibraryItem') + db_alias = schema_editor.connection.alias + for item in LibraryItem.objects.using(db_alias).all(): + if item.is_canonical: + location = m.LocationHead.LIBRARY + elif item.is_common: + location = m.LocationHead.COMMON + else: + location = m.LocationHead.USER + item.location = location + item.save() + + operations = [ + migrations.AddField( + model_name='libraryitem', + name='access_policy', + field=models.CharField( + choices=[ + ('public', 'Public'), + ('protected', 'Protected'), + ('private', 'Private') + ], + default='public', + max_length=500, + verbose_name='Политика доступа'), + ), + migrations.AddField( + model_name='libraryitem', + name='location', + field=models.TextField(default='/U', max_length=500, verbose_name='Расположение'), + ), + migrations.AddField( + model_name='libraryitem', + name='read_only', + field=models.BooleanField(default=False, verbose_name='Запретить редактирование'), + ), + migrations.AddField( + model_name='libraryitem', + name='visible', + field=models.BooleanField(default=True, verbose_name='Отображаемая'), + ), + migrations.RunPython(calculate_location, migrations.RunPython.noop), # type: ignore + migrations.RemoveField( + model_name='libraryitem', + name='is_canonical', + ), + migrations.RemoveField( + model_name='libraryitem', + name='is_common', + ), + ] diff --git a/rsconcept/backend/apps/rsform/models/LibraryItem.py b/rsconcept/backend/apps/rsform/models/LibraryItem.py index a4a6ccdd..144c6116 100644 --- a/rsconcept/backend/apps/rsform/models/LibraryItem.py +++ b/rsconcept/backend/apps/rsform/models/LibraryItem.py @@ -1,4 +1,6 @@ ''' Models: LibraryItem. ''' +import re + from django.db import transaction from django.db.models import ( SET_NULL, @@ -24,6 +26,28 @@ class LibraryItemType(TextChoices): OPERATIONS_SCHEMA = 'oss' +class AccessPolicy(TextChoices): + ''' Type of item access policy. ''' + PUBLIC = 'public' + PROTECTED = 'protected' + PRIVATE = 'private' + + +class LocationHead(TextChoices): + ''' Location prefixes. ''' + PROJECTS = '/P' + LIBRARY = '/L' + USER = '/U' + COMMON = '/S' + + +_RE_LOCATION = r'^/[PLUS]((/[!\d\w]([!\d\w ]*[!\d\w])?)*)?$' # cspell:disable-line + + +def validate_location(target: str) -> bool: + return bool(re.search(_RE_LOCATION, target)) + + class LibraryItem(Model): ''' Abstract library item.''' item_type: CharField = CharField( @@ -49,14 +73,26 @@ class LibraryItem(Model): verbose_name='Комментарий', blank=True ) - is_common: BooleanField = BooleanField( - verbose_name='Общая', + visible: BooleanField = BooleanField( + verbose_name='Отображаемая', + default=True + ) + read_only: BooleanField = BooleanField( + verbose_name='Запретить редактирование', default=False ) - is_canonical: BooleanField = BooleanField( - verbose_name='Каноничная', - default=False + access_policy: CharField = CharField( + verbose_name='Политика доступа', + max_length=500, + choices=AccessPolicy.choices, + default=AccessPolicy.PUBLIC ) + location: TextField = TextField( + verbose_name='Расположение', + max_length=500, + default=LocationHead.USER + ) + time_create: DateTimeField = DateTimeField( verbose_name='Дата создания', auto_now_add=True diff --git a/rsconcept/backend/apps/rsform/models/__init__.py b/rsconcept/backend/apps/rsform/models/__init__.py index 0f9ea349..3ea203d6 100644 --- a/rsconcept/backend/apps/rsform/models/__init__.py +++ b/rsconcept/backend/apps/rsform/models/__init__.py @@ -3,7 +3,14 @@ from .api_RSForm import RSForm from .Constituenta import Constituenta, CstType, _empty_forms from .Editor import Editor -from .LibraryItem import LibraryItem, LibraryItemType, User +from .LibraryItem import ( + AccessPolicy, + LibraryItem, + LibraryItemType, + LocationHead, + User, + validate_location +) from .LibraryTemplate import LibraryTemplate from .Subscription import Subscription from .Version import Version diff --git a/rsconcept/backend/apps/rsform/permissions.py b/rsconcept/backend/apps/rsform/permissions.py index 874fca17..ee51a795 100644 --- a/rsconcept/backend/apps/rsform/permissions.py +++ b/rsconcept/backend/apps/rsform/permissions.py @@ -57,10 +57,11 @@ class ItemEditor(ItemOwner): ''' Item permission: Editor or higher. ''' def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: + item = _extract_item(obj) if m.Editor.objects.filter( - item=_extract_item(obj), + item=item, editor=cast(m.User, request.user) - ).exists(): + ).exists() and item.access_policy != m.AccessPolicy.PRIVATE: return True return super().has_object_permission(request, view, obj) diff --git a/rsconcept/backend/apps/rsform/serializers/__init__.py b/rsconcept/backend/apps/rsform/serializers/__init__.py index a9840e63..921ef840 100644 --- a/rsconcept/backend/apps/rsform/serializers/__init__.py +++ b/rsconcept/backend/apps/rsform/serializers/__init__.py @@ -1,9 +1,11 @@ ''' REST API: Serializers. ''' from .basics import ( + AccessPolicySerializer, ASTNodeSerializer, ExpressionParseSerializer, ExpressionSerializer, + LocationSerializer, MultiFormSerializer, ResolverSerializer, TextSerializer, @@ -18,6 +20,7 @@ from .data_access import ( CstSubstituteSerializer, CstTargetSerializer, InlineSynthesisSerializer, + LibraryItemBase, LibraryItemCloneSerializer, LibraryItemSerializer, RSFormParseSerializer, diff --git a/rsconcept/backend/apps/rsform/serializers/basics.py b/rsconcept/backend/apps/rsform/serializers/basics.py index 40afd0a5..cc21c83f 100644 --- a/rsconcept/backend/apps/rsform/serializers/basics.py +++ b/rsconcept/backend/apps/rsform/serializers/basics.py @@ -4,6 +4,9 @@ from typing import cast from cctext import EntityReference, Reference, ReferenceType, Resolver, SyntacticReference from rest_framework import serializers +from .. import messages as msg +from ..models import AccessPolicy, validate_location + class ExpressionSerializer(serializers.Serializer): ''' Serializer: RSLang expression. ''' @@ -16,6 +19,32 @@ class WordFormSerializer(serializers.Serializer): grams = serializers.CharField() +class LocationSerializer(serializers.Serializer): + ''' Serializer: Item location. ''' + location = serializers.CharField(max_length=500) + + def validate(self, attrs): + attrs = super().validate(attrs) + if not validate_location(attrs['location']): + raise serializers.ValidationError({ + 'location': msg.invalidLocation() + }) + return attrs + + +class AccessPolicySerializer(serializers.Serializer): + ''' Serializer: Constituenta renaming. ''' + access_policy = serializers.CharField(max_length=500) + + def validate(self, attrs): + attrs = super().validate(attrs) + if not attrs['access_policy'] in AccessPolicy.values: + raise serializers.ValidationError({ + 'access_policy': msg.invalidEnum(attrs['access_policy']) + }) + return attrs + + class MultiFormSerializer(serializers.Serializer): ''' Serializer: inflect request. ''' items = serializers.ListField( diff --git a/rsconcept/backend/apps/rsform/serializers/data_access.py b/rsconcept/backend/apps/rsform/serializers/data_access.py index 078fa000..1e63b952 100644 --- a/rsconcept/backend/apps/rsform/serializers/data_access.py +++ b/rsconcept/backend/apps/rsform/serializers/data_access.py @@ -13,8 +13,8 @@ from .basics import CstParseSerializer from .io_pyconcept import PyConceptAdapter -class LibraryItemSerializer(serializers.ModelSerializer): - ''' Serializer: LibraryItem entry. ''' +class LibraryItemBase(serializers.ModelSerializer): + ''' Serializer: LibraryItem entry full access. ''' class Meta: ''' serializer metadata. ''' model = LibraryItem @@ -22,6 +22,15 @@ class LibraryItemSerializer(serializers.ModelSerializer): read_only_fields = ('id', 'item_type') +class LibraryItemSerializer(serializers.ModelSerializer): + ''' Serializer: LibraryItem entry limited access. ''' + class Meta: + ''' serializer metadata. ''' + model = LibraryItem + fields = '__all__' + read_only_fields = ('id', 'item_type', 'owner', 'location', 'access_policy') + + class VersionSerializer(serializers.ModelSerializer): ''' Serializer: Version data. ''' class Meta: @@ -164,8 +173,11 @@ class RSFormSerializer(serializers.ModelSerializer): del result['editors'] del result['owner'] - del result['is_common'] - del result['is_canonical'] + del result['visible'] + del result['read_only'] + del result['access_policy'] + del result['location'] + del result['time_create'] del result['time_update'] return result @@ -208,7 +220,7 @@ class RSFormSerializer(serializers.ModelSerializer): validated_data=new_cst.validated_data ) - loaded_item = LibraryItemSerializer(data=data) + loaded_item = LibraryItemBase(data=data) loaded_item.is_valid(raise_exception=True) loaded_item.update( instance=cast(LibraryItem, self.instance), @@ -325,7 +337,7 @@ class CstListSerializer(serializers.Serializer): return attrs -class LibraryItemCloneSerializer(LibraryItemSerializer): +class LibraryItemCloneSerializer(LibraryItemBase): ''' Serializer: LibraryItem cloning. ''' items = PKField(many=True, required=False, queryset=Constituenta.objects.all()) diff --git a/rsconcept/backend/apps/rsform/serializers/io_files.py b/rsconcept/backend/apps/rsform/serializers/io_files.py index 8dc3783a..00065236 100644 --- a/rsconcept/backend/apps/rsform/serializers/io_files.py +++ b/rsconcept/backend/apps/rsform/serializers/io_files.py @@ -109,10 +109,14 @@ class RSFormTRSSerializer(serializers.Serializer): result = super().to_internal_value(data) if 'owner' in data: result['owner'] = data['owner'] - if 'is_common' in data: - result['is_common'] = data['is_common'] - if 'is_canonical' in data: - result['is_canonical'] = data['is_canonical'] + if 'visible' in data: + result['visible'] = data['visible'] + if 'read_only' in data: + result['read_only'] = data['read_only'] + if 'access_policy' in data: + result['access_policy'] = data['access_policy'] + if 'location' in data: + result['location'] = data['location'] result['items'] = data.get('items', []) if self.context['load_meta']: result['title'] = data.get('title', 'Без названия') @@ -139,8 +143,10 @@ class RSFormTRSSerializer(serializers.Serializer): alias=validated_data['alias'], title=validated_data['title'], comment=validated_data['comment'], - is_common=validated_data['is_common'], - is_canonical=validated_data['is_canonical'] + visible=validated_data['visible'], + read_only=validated_data['read_only'], + access_policy=validated_data['access_policy'], + location=validated_data['location'] ) self.instance.item.save() order = 1 diff --git a/rsconcept/backend/apps/rsform/tests/EndpointTester.py b/rsconcept/backend/apps/rsform/tests/EndpointTester.py index 6f1b94bf..e2436cc4 100644 --- a/rsconcept/backend/apps/rsform/tests/EndpointTester.py +++ b/rsconcept/backend/apps/rsform/tests/EndpointTester.py @@ -2,6 +2,7 @@ from rest_framework import status from rest_framework.test import APIClient, APIRequestFactory, APITestCase +from apps.rsform.models import Editor, LibraryItem from apps.users.models import User @@ -43,6 +44,12 @@ class EndpointTester(APITestCase): self.user.is_staff = value self.user.save() + def toggle_editor(self, item: LibraryItem, value: bool = True): + if value: + Editor.add(item, self.user) + else: + Editor.remove(item, self.user) + def login(self): self.client.force_authenticate(user=self.user) diff --git a/rsconcept/backend/apps/rsform/tests/s_models/t_LibraryItem.py b/rsconcept/backend/apps/rsform/tests/s_models/t_LibraryItem.py index 997d4eb0..5b6bed05 100644 --- a/rsconcept/backend/apps/rsform/tests/s_models/t_LibraryItem.py +++ b/rsconcept/backend/apps/rsform/tests/s_models/t_LibraryItem.py @@ -1,7 +1,15 @@ ''' Testing models: LibraryItem. ''' from django.test import TestCase -from apps.rsform.models import LibraryItem, LibraryItemType, Subscription, User +from apps.rsform.models import ( + AccessPolicy, + LibraryItem, + LibraryItemType, + LocationHead, + Subscription, + User, + validate_location +) class TestLibraryItem(TestCase): @@ -40,8 +48,10 @@ class TestLibraryItem(TestCase): self.assertEqual(item.title, 'Test') self.assertEqual(item.alias, '') self.assertEqual(item.comment, '') - self.assertEqual(item.is_common, False) - self.assertEqual(item.is_canonical, False) + self.assertEqual(item.visible, True) + self.assertEqual(item.read_only, False) + self.assertEqual(item.access_policy, AccessPolicy.PUBLIC) + self.assertEqual(item.location, LocationHead.USER) def test_create(self): @@ -51,13 +61,39 @@ class TestLibraryItem(TestCase): owner=self.user1, alias='KS1', comment='Test comment', - is_common=True, - is_canonical=True + location=LocationHead.COMMON ) self.assertEqual(item.owner, self.user1) self.assertEqual(item.title, 'Test') self.assertEqual(item.alias, 'KS1') self.assertEqual(item.comment, 'Test comment') - self.assertEqual(item.is_common, True) - self.assertEqual(item.is_canonical, True) + self.assertEqual(item.location, LocationHead.COMMON) self.assertTrue(Subscription.objects.filter(user=item.owner, item=item).exists()) + + +class TestLocation(TestCase): + ''' Testing Location model. ''' + + def test_validate_location(self): + self.assertFalse(validate_location('')) + self.assertFalse(validate_location('/A')) + self.assertFalse(validate_location('U/U')) + self.assertFalse(validate_location('/U/')) + self.assertFalse(validate_location('/U/user@mail')) + self.assertFalse(validate_location('/U/u\\asdf')) + self.assertFalse(validate_location('/U/ asdf')) + self.assertFalse(validate_location('/User')) + self.assertFalse(validate_location('//')) + self.assertFalse(validate_location('/S/1/')) + self.assertFalse(validate_location('/S/1 ')) + self.assertFalse(validate_location('/S/1/2 /3')) + + self.assertTrue(validate_location('/P')) + self.assertTrue(validate_location('/L')) + self.assertTrue(validate_location('/U')) + self.assertTrue(validate_location('/S')) + self.assertTrue(validate_location('/S/1')) + self.assertTrue(validate_location('/S/12')) + self.assertTrue(validate_location('/S/12/3')) + self.assertTrue(validate_location('/S/Вася пупки')) + self.assertTrue(validate_location('/S/1/!asdf/тест тест')) diff --git a/rsconcept/backend/apps/rsform/tests/s_views/t_library.py b/rsconcept/backend/apps/rsform/tests/s_views/t_library.py index bb99dba3..e560f4e1 100644 --- a/rsconcept/backend/apps/rsform/tests/s_views/t_library.py +++ b/rsconcept/backend/apps/rsform/tests/s_views/t_library.py @@ -2,10 +2,12 @@ from rest_framework import status from apps.rsform.models import ( + AccessPolicy, Editor, LibraryItem, LibraryItemType, LibraryTemplate, + LocationHead, RSForm, Subscription ) @@ -35,7 +37,7 @@ class TestLibraryViewset(EndpointTester): item_type=LibraryItemType.RSFORM, title='Test3', alias='T3', - is_common=True + location=LocationHead.COMMON ) self.invalid_user = 1337 + self.user2.pk self.invalid_item = 1337 + self.common.pk @@ -55,15 +57,37 @@ class TestLibraryViewset(EndpointTester): @decl_endpoint('/api/library/{item}', method='patch') def test_update(self): - data = {'id': self.unowned.pk, 'title': 'New title'} + data = {'id': self.unowned.pk, 'title': 'New Title'} self.executeNotFound(data, item=self.invalid_item) self.executeForbidden(data, item=self.unowned.pk) - data = {'id': self.owned.pk, 'title': 'New title'} + self.toggle_editor(self.unowned, True) + response = self.executeOK(data, item=self.unowned.pk) + self.assertEqual(response.data['title'], data['title']) + + self.unowned.access_policy = AccessPolicy.PRIVATE + self.unowned.save() + self.executeForbidden(data, item=self.unowned.pk) + + data = {'id': self.owned.pk, 'title': 'New Title'} response = self.executeOK(data, item=self.owned.pk) - self.assertEqual(response.data['title'], 'New title') + self.assertEqual(response.data['title'], data['title']) self.assertEqual(response.data['alias'], self.owned.alias) + data = { + 'id': self.owned.pk, + 'title': 'Another Title', + 'owner': self.user2.pk, + 'access_policy': AccessPolicy.PROTECTED, + 'location': LocationHead.LIBRARY + } + response = self.executeOK(data, item=self.owned.pk) + self.assertEqual(response.data['title'], data['title']) + self.assertEqual(response.data['owner'], self.owned.owner.pk) + self.assertEqual(response.data['access_policy'], self.owned.access_policy) + self.assertEqual(response.data['location'], self.owned.location) + self.assertNotEqual(response.data['location'], LocationHead.LIBRARY) + @decl_endpoint('/api/library/{item}/set-owner', method='patch') def test_set_owner(self): @@ -89,6 +113,59 @@ class TestLibraryViewset(EndpointTester): self.owned.refresh_from_db() self.assertEqual(self.owned.owner, self.user) + @decl_endpoint('/api/library/{item}/set-access-policy', method='patch') + def test_set_access_policy(self): + time_update = self.owned.time_update + + data = {'access_policy': 'invalid'} + self.executeBadData(data, item=self.owned.pk) + + data = {'access_policy': AccessPolicy.PRIVATE} + self.executeNotFound(data, item=self.invalid_item) + self.executeForbidden(data, item=self.unowned.pk) + self.executeOK(data, item=self.owned.pk) + self.owned.refresh_from_db() + self.assertEqual(self.owned.access_policy, data['access_policy']) + + self.toggle_editor(self.unowned, True) + self.executeForbidden(data, item=self.unowned.pk) + + self.toggle_admin(True) + self.executeOK(data, item=self.unowned.pk) + self.unowned.refresh_from_db() + self.assertEqual(self.unowned.access_policy, data['access_policy']) + + @decl_endpoint('/api/library/{item}/set-location', method='patch') + def test_set_location(self): + time_update = self.owned.time_update + + data = {'location': 'invalid'} + self.executeBadData(data, item=self.owned.pk) + + data = {'location': '/U/temp'} + self.executeNotFound(data, item=self.invalid_item) + self.executeForbidden(data, item=self.unowned.pk) + self.executeOK(data, item=self.owned.pk) + self.owned.refresh_from_db() + self.assertEqual(self.owned.location, data['location']) + + data = {'location': LocationHead.LIBRARY} + self.executeForbidden(data, item=self.owned.pk) + + data = {'location': '/U/temp'} + self.toggle_editor(self.unowned, True) + self.executeForbidden(data, item=self.unowned.pk) + + self.toggle_admin(True) + data = {'location': LocationHead.LIBRARY} + self.executeOK(data, item=self.owned.pk) + self.owned.refresh_from_db() + self.assertEqual(self.owned.location, data['location']) + + self.executeOK(data, item=self.unowned.pk) + self.unowned.refresh_from_db() + self.assertEqual(self.unowned.location, data['location']) + @decl_endpoint('/api/library/{item}/editors-add', method='patch') def test_add_editor(self): time_update = self.owned.time_update diff --git a/rsconcept/backend/apps/rsform/tests/s_views/t_rsforms.py b/rsconcept/backend/apps/rsform/tests/s_views/t_rsforms.py index 3ad506e3..3e2a52b1 100644 --- a/rsconcept/backend/apps/rsform/tests/s_views/t_rsforms.py +++ b/rsconcept/backend/apps/rsform/tests/s_views/t_rsforms.py @@ -6,7 +6,15 @@ from zipfile import ZipFile from cctext import ReferenceType from rest_framework import status -from apps.rsform.models import Constituenta, CstType, LibraryItem, LibraryItemType, RSForm +from apps.rsform.models import ( + AccessPolicy, + Constituenta, + CstType, + LibraryItem, + LibraryItemType, + LocationHead, + RSForm +) from ..EndpointTester import EndpointTester, decl_endpoint from ..testing_utils import response_contains @@ -38,12 +46,21 @@ class TestRSFormViewset(EndpointTester): @decl_endpoint('/api/rsforms/create-detailed', method='post') def test_create_rsform_json(self): - data = {'title': 'Test123', 'comment': '123', 'alias': 'ks1'} + data = { + 'title': 'Test123', + 'comment': '123', + 'alias': 'ks1', + 'location': LocationHead.PROJECTS, + 'access_policy': AccessPolicy.PROTECTED, + 'visible': False + } response = self.executeCreated(data) self.assertEqual(response.data['owner'], self.user.pk) - self.assertEqual(response.data['title'], 'Test123') - self.assertEqual(response.data['alias'], 'ks1') - self.assertEqual(response.data['comment'], '123') + self.assertEqual(response.data['title'], data['title']) + self.assertEqual(response.data['alias'], data['alias']) + self.assertEqual(response.data['location'], data['location']) + self.assertEqual(response.data['access_policy'], data['access_policy']) + self.assertEqual(response.data['visible'], data['visible']) @decl_endpoint('/api/rsforms', method='get') diff --git a/rsconcept/backend/apps/rsform/views/library.py b/rsconcept/backend/apps/rsform/views/library.py index 3024d573..fd9d2a89 100644 --- a/rsconcept/backend/apps/rsform/views/library.py +++ b/rsconcept/backend/apps/rsform/views/library.py @@ -27,12 +27,26 @@ class LibraryActiveView(generics.ListAPIView): def get_queryset(self): if self.request.user.is_anonymous: - return m.LibraryItem.objects.filter(is_common=True).order_by('-time_update') + return m.LibraryItem.objects.filter( + Q(access_policy=m.AccessPolicy.PUBLIC), + ).filter( + Q(location__startswith=m.LocationHead.COMMON) | + Q(location__startswith=m.LocationHead.LIBRARY) + ).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) + ( + Q(access_policy=m.AccessPolicy.PUBLIC) & + ( + Q(location__startswith=m.LocationHead.COMMON) | + Q(location__startswith=m.LocationHead.LIBRARY) + ) + ) | + Q(owner=user) | + Q(editor__editor=user) | + Q(subscription__user=user) ).distinct().order_by('-time_update') @@ -68,8 +82,8 @@ class LibraryViewSet(viewsets.ModelViewSet): serializer_class = s.LibraryItemSerializer filter_backends = (DjangoFilterBackend, filters.OrderingFilter) - filterset_fields = ['item_type', 'owner', 'is_common', 'is_canonical'] - ordering_fields = ('item_type', 'owner', 'title', 'time_update') + filterset_fields = ['item_type', 'owner'] + ordering_fields = ('item_type', 'owner', 'alias', 'title', 'time_update') ordering = '-time_update' def perform_create(self, serializer): @@ -82,7 +96,7 @@ class LibraryViewSet(viewsets.ModelViewSet): if self.action in ['update', 'partial_update']: permission_list = [permissions.ItemEditor] elif self.action in [ - 'destroy', 'set_owner', + 'destroy', 'set_owner', 'set_access_policy', 'set_location', 'editors_add', 'editors_remove', 'editors_set' ]: permission_list = [permissions.ItemOwner] @@ -119,12 +133,13 @@ class LibraryViewSet(viewsets.ModelViewSet): clone.title = serializer.validated_data['title'] clone.alias = serializer.validated_data.get('alias', '') clone.comment = serializer.validated_data.get('comment', '') - clone.is_common = serializer.validated_data.get('is_common', False) - clone.is_canonical = False + clone.visible = serializer.validated_data.get('visible', True) + clone.read_only = False + clone.access_policy = serializer.validated_data.get('access_policy', m.AccessPolicy.PUBLIC) + clone.location = serializer.validated_data.get('location', m.LocationHead.USER) + clone.save() if clone.item_type == m.LibraryItemType.RSFORM: - clone.save() - need_filter = 'items' in request.data for cst in m.RSForm(item).constituents(): if not need_filter or cst.pk in request.data['items']: @@ -191,6 +206,49 @@ class LibraryViewSet(viewsets.ModelViewSet): m.LibraryItem.objects.filter(pk=item.pk).update(owner=new_owner) return Response(status=c.HTTP_200_OK) + @extend_schema( + summary='set AccessPolicy for item', + tags=['Library'], + request=s.AccessPolicySerializer, + responses={ + c.HTTP_200_OK: None, + c.HTTP_400_BAD_REQUEST: None, + c.HTTP_403_FORBIDDEN: None, + c.HTTP_404_NOT_FOUND: None + } + ) + @action(detail=True, methods=['patch'], url_path='set-access-policy') + def set_access_policy(self, request: Request, pk): + ''' Endpoint: Set item AccessPolicy. ''' + item = self._get_item() + serializer = s.AccessPolicySerializer(data=request.data) + serializer.is_valid(raise_exception=True) + m.LibraryItem.objects.filter(pk=item.pk).update(access_policy=serializer.validated_data['access_policy']) + return Response(status=c.HTTP_200_OK) + + @extend_schema( + summary='set location for item', + tags=['Library'], + request=s.LocationSerializer, + responses={ + c.HTTP_200_OK: None, + c.HTTP_400_BAD_REQUEST: None, + c.HTTP_403_FORBIDDEN: None, + c.HTTP_404_NOT_FOUND: None + } + ) + @action(detail=True, methods=['patch'], url_path='set-location') + def set_location(self, request: Request, pk): + ''' Endpoint: Set item location. ''' + item = self._get_item() + serializer = s.LocationSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + location: str = serializer.validated_data['location'] + if location.startswith(m.LocationHead.LIBRARY) and not self.request.user.is_staff: + return Response(status=c.HTTP_403_FORBIDDEN) + m.LibraryItem.objects.filter(pk=item.pk).update(location=location) + return Response(status=c.HTTP_200_OK) + @extend_schema( summary='add editor for item', tags=['Library'], diff --git a/rsconcept/backend/apps/rsform/views/rsforms.py b/rsconcept/backend/apps/rsform/views/rsforms.py index 8ae2baa6..e1cbc57f 100644 --- a/rsconcept/backend/apps/rsform/views/rsforms.py +++ b/rsconcept/backend/apps/rsform/views/rsforms.py @@ -447,15 +447,17 @@ def create_rsform(request: Request): ''' Endpoint: Create RSForm from user input and/or trs file. ''' 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 = s.LibraryItemBase(data=request.data) serializer.is_valid(raise_exception=True) schema = m.RSForm.create( title=serializer.validated_data['title'], owner=owner, alias=serializer.validated_data.get('alias', ''), comment=serializer.validated_data.get('comment', ''), - is_common=serializer.validated_data.get('is_common', False), - is_canonical=serializer.validated_data.get('is_canonical', False), + visible=serializer.validated_data.get('visible', True), + read_only=serializer.validated_data.get('read_only', False), + access_policy=serializer.validated_data.get('access_policy', m.AccessPolicy.PUBLIC), + location=serializer.validated_data.get('location', m.LocationHead.USER), ) else: data = utils.read_zipped_json(request.FILES['file'].file, utils.EXTEOR_INNER_FILENAME) @@ -481,12 +483,15 @@ def _prepare_rsform_data(data: dict, request: Request, owner: Union[m.User, None if 'comment' in request.data and request.data['comment'] != '': data['comment'] = request.data['comment'] - is_common = True - if 'is_common' in request.data: - is_common = request.data['is_common'] == 'true' - data['is_common'] = is_common + visible = True + if 'visible' in request.data: + visible = request.data['visible'] == 'true' + data['visible'] = visible - is_canonical = False - if 'is_canonical' in request.data: - is_canonical = request.data['is_canonical'] == 'true' - data['is_canonical'] = is_canonical + read_only = False + if 'read_only' in request.data: + read_only = request.data['read_only'] == 'true' + data['read_only'] = read_only + + data['access_policy'] = request.data.get('access_policy', m.AccessPolicy.PUBLIC) + data['location'] = request.data.get('location', m.LocationHead.USER) diff --git a/rsconcept/frontend/src/app/ApplicationLayout.tsx b/rsconcept/frontend/src/app/ApplicationLayout.tsx index 8953f0a4..48612a6e 100644 --- a/rsconcept/frontend/src/app/ApplicationLayout.tsx +++ b/rsconcept/frontend/src/app/ApplicationLayout.tsx @@ -11,7 +11,7 @@ function ApplicationLayout() { const { viewportHeight, mainHeight, showScroll } = useConceptOptions(); return ( -
+
-
+