diff --git a/rsconcept/backend/apps/rsform/permissions.py b/rsconcept/backend/apps/rsform/permissions.py new file mode 100644 index 00000000..874fca17 --- /dev/null +++ b/rsconcept/backend/apps/rsform/permissions.py @@ -0,0 +1,77 @@ +''' Custom Permission classes. + Hierarchy: Anonymous -> User -> Editor -> Owner -> Admin +''' +from typing import Any, cast + +from django.core.exceptions import PermissionDenied +from rest_framework.permissions import AllowAny as Anyone # pylint: disable=unused-import +from rest_framework.permissions import BasePermission as _Base +from rest_framework.permissions import \ + IsAuthenticated as GlobalUser # pylint: disable=unused-import +from rest_framework.request import Request +from rest_framework.views import APIView + +from . import models as m + + +def _extract_item(obj: Any) -> m.LibraryItem: + if isinstance(obj, m.LibraryItem): + return obj + elif isinstance(obj, m.Constituenta): + return cast(m.LibraryItem, obj.schema) + elif isinstance(obj, (m.Version, m.Subscription, m.Editor)): + return cast(m.LibraryItem, obj.item) + raise PermissionDenied({ + 'message': 'Invalid type error. Please contact developers', + 'object_id': obj.id + }) + + +class GlobalAdmin(_Base): + ''' Item permission: Admin or higher. ''' + + def has_permission(self, request: Request, view: APIView) -> bool: + if not hasattr(request.user, 'is_staff'): + return False + return request.user.is_staff # type: ignore + + def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: + if not hasattr(request.user, 'is_staff'): + return False + return request.user.is_staff # type: ignore + + +class ItemOwner(GlobalAdmin): + ''' Item permission: Owner or higher. ''' + + def has_permission(self, request: Request, view: APIView) -> bool: + return not request.user.is_anonymous + + def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: + if request.user == _extract_item(obj).owner: + return True + return super().has_object_permission(request, view, obj) + + +class ItemEditor(ItemOwner): + ''' Item permission: Editor or higher. ''' + + def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: + if m.Editor.objects.filter( + item=_extract_item(obj), + editor=cast(m.User, request.user) + ).exists(): + return True + return super().has_object_permission(request, view, obj) + + +class EditorMixin(APIView): + ''' Editor permissions mixin for API views. ''' + + def get_permissions(self): + result = super().get_permissions() + if self.request.method.upper() == 'GET': + result.append(Anyone()) + else: + result.append(ItemEditor()) + return result 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 794b8688..5831409d 100644 --- a/rsconcept/backend/apps/rsform/tests/s_views/t_library.py +++ b/rsconcept/backend/apps/rsform/tests/s_views/t_library.py @@ -1,16 +1,16 @@ ''' Testing API: Library. ''' from rest_framework import status +from apps.rsform.models import LibraryItem, LibraryItemType, LibraryTemplate, RSForm, Subscription from apps.users.models import User -from apps.rsform.models import LibraryItem, LibraryItemType, Subscription, LibraryTemplate, RSForm from ..testing_utils import response_contains - -from .EndpointTester import decl_endpoint, EndpointTester +from .EndpointTester import EndpointTester, decl_endpoint class TestLibraryViewset(EndpointTester): ''' Testing Library view. ''' + def setUp(self): super().setUp() self.owned = LibraryItem.objects.create( @@ -71,30 +71,6 @@ class TestLibraryViewset(EndpointTester): self.assertTrue(response.status_code in [status.HTTP_202_ACCEPTED, status.HTTP_204_NO_CONTENT]) - @decl_endpoint('/api/library/{item}/claim', method='post') - def test_claim(self): - self.assertNotFound(item=self.invalid_item) - self.assertForbidden(item=self.owned.id) - - self.owned.is_common = True - self.owned.save() - self.assertNotModified(item=self.owned.id) - self.assertForbidden(item=self.unowned.id) - - self.assertFalse(self.user in self.unowned.subscribers()) - self.unowned.is_common = True - self.unowned.save() - - self.assertOK(item=self.unowned.id) - self.unowned.refresh_from_db() - self.assertEqual(self.unowned.owner, self.user) - self.assertEqual(self.unowned.owner, self.user) - self.assertTrue(self.user in self.unowned.subscribers()) - - self.logout() - self.assertForbidden(item=self.owned.id) - - @decl_endpoint('/api/library/active', method='get') def test_retrieve_common(self): response = self.execute() @@ -132,7 +108,7 @@ class TestLibraryViewset(EndpointTester): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertFalse(response_contains(response, self.unowned)) - user2 = User.objects.create(username='UserTest2') + user2 = User.objects.create(username='UserTest2') Subscription.subscribe(user=self.user, item=self.unowned) Subscription.subscribe(user=user2, item=self.unowned) Subscription.subscribe(user=user2, item=self.owned) @@ -183,13 +159,13 @@ class TestLibraryViewset(EndpointTester): def test_clone_rsform(self): x12 = self.schema.insert_new( alias='X12', - term_raw = 'человек', - term_resolved = 'человек' + term_raw='человек', + term_resolved='человек' ) d2 = self.schema.insert_new( alias='D2', - term_raw = '@{X12|plur}', - term_resolved = 'люди' + term_raw='@{X12|plur}', + term_resolved='люди' ) data = {'title': 'Title1337'} diff --git a/rsconcept/backend/apps/rsform/utils.py b/rsconcept/backend/apps/rsform/utils.py index b5e73e1f..5b3e1d80 100644 --- a/rsconcept/backend/apps/rsform/utils.py +++ b/rsconcept/backend/apps/rsform/utils.py @@ -4,8 +4,6 @@ import re from io import BytesIO from zipfile import ZipFile -from rest_framework.permissions import BasePermission, IsAuthenticated - # Name for JSON inside Exteor files archive EXTEOR_INNER_FILENAME = 'document.json' @@ -13,48 +11,6 @@ EXTEOR_INNER_FILENAME = 'document.json' _REF_OLD_PATTERN = re.compile(r'@{([^0-9\-][^\}\|\{]*?)\|([^\}\|\{]*?)\|([^\}\|\{]*?)}') -class ObjectOwnerOrAdmin(BasePermission): - ''' Permission for object ownership restriction ''' - - def has_object_permission(self, request, view, obj): - if request.user == obj.owner: - return True - if not hasattr(request.user, 'is_staff'): - return False - return request.user.is_staff # type: ignore - - -class IsClaimable(IsAuthenticated): - ''' Permission for object ownership restriction ''' - - def has_object_permission(self, request, view, obj): - if not super().has_permission(request, view): - return False - return obj.is_common - - -class SchemaOwnerOrAdmin(BasePermission): - ''' Permission for object ownership restriction ''' - - def has_object_permission(self, request, view, obj): - if request.user == obj.schema.owner: - return True - if not hasattr(request.user, 'is_staff'): - return False - return request.user.is_staff # type: ignore - - -class ItemOwnerOrAdmin(BasePermission): - ''' Permission for object ownership restriction ''' - - def has_object_permission(self, request, view, obj): - if request.user == obj.item.owner: - return True - if not hasattr(request.user, 'is_staff'): - return False - return request.user.is_staff # type: ignore - - def read_zipped_json(data, json_filename: str) -> dict: ''' Read JSON from zipped data ''' with ZipFile(data, 'r') as archive: diff --git a/rsconcept/backend/apps/rsform/views/constituents.py b/rsconcept/backend/apps/rsform/views/constituents.py index 55c81c96..cd772f1c 100644 --- a/rsconcept/backend/apps/rsform/views/constituents.py +++ b/rsconcept/backend/apps/rsform/views/constituents.py @@ -1,23 +1,15 @@ ''' Endpoints for Constituenta. ''' from drf_spectacular.utils import extend_schema, extend_schema_view -from rest_framework import generics, permissions +from rest_framework import generics from .. import models as m +from .. import permissions from .. import serializers as s -from .. import utils @extend_schema(tags=['Constituenta']) @extend_schema_view() -class ConstituentAPIView(generics.RetrieveUpdateAPIView): +class ConstituentAPIView(generics.RetrieveUpdateAPIView, permissions.EditorMixin): ''' Endpoint: Get / Update Constituenta. ''' queryset = m.Constituenta.objects.all() serializer_class = s.CstSerializer - - def get_permissions(self): - result = super().get_permissions() - if self.request.method.upper() == 'GET': - result.append(permissions.AllowAny()) - else: - result.append(utils.SchemaOwnerOrAdmin()) - return result diff --git a/rsconcept/backend/apps/rsform/views/library.py b/rsconcept/backend/apps/rsform/views/library.py index 6a6d4136..ee610040 100644 --- a/rsconcept/backend/apps/rsform/views/library.py +++ b/rsconcept/backend/apps/rsform/views/library.py @@ -6,7 +6,7 @@ from django.db import transaction from django.db.models import Q from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.utils import extend_schema, extend_schema_view -from rest_framework import filters, generics, permissions +from rest_framework import filters, generics from rest_framework import status as c from rest_framework import viewsets from rest_framework.decorators import action @@ -14,15 +14,15 @@ from rest_framework.request import Request from rest_framework.response import Response from .. import models as m +from .. import permissions from .. import serializers as s -from .. import utils @extend_schema(tags=['Library']) @extend_schema_view() class LibraryActiveView(generics.ListAPIView): ''' Endpoint: Get list of library items available for active user. ''' - permission_classes = (permissions.AllowAny,) + permission_classes = (permissions.Anyone,) serializer_class = s.LibraryItemSerializer def get_queryset(self): @@ -40,7 +40,7 @@ class LibraryActiveView(generics.ListAPIView): @extend_schema_view() class LibraryAdminView(generics.ListAPIView): ''' Endpoint: Get list of all library items. Admin only ''' - permission_classes = (permissions.IsAdminUser,) + permission_classes = (permissions.GlobalAdmin,) serializer_class = s.LibraryItemSerializer def get_queryset(self): @@ -51,7 +51,7 @@ class LibraryAdminView(generics.ListAPIView): @extend_schema_view() class LibraryTemplatesView(generics.ListAPIView): ''' Endpoint: Get list of templates. ''' - permission_classes = (permissions.AllowAny,) + permission_classes = (permissions.Anyone,) serializer_class = s.LibraryItemSerializer def get_queryset(self): @@ -79,14 +79,14 @@ class LibraryViewSet(viewsets.ModelViewSet): return serializer.save() def get_permissions(self): - if self.action in ['update', 'destroy', 'partial_update']: - permission_list = [utils.ObjectOwnerOrAdmin] + if self.action in ['destroy']: + permission_list = [permissions.ItemOwner] + elif self.action in ['update', 'partial_update']: + permission_list = [permissions.ItemEditor] elif self.action in ['create', 'clone', 'subscribe', 'unsubscribe']: - permission_list = [permissions.IsAuthenticated] - elif self.action in ['claim']: - permission_list = [utils.IsClaimable] + permission_list = [permissions.GlobalUser] else: - permission_list = [permissions.AllowAny] + permission_list = [permissions.Anyone] return [permission() for permission in permission_list] def _get_item(self) -> m.LibraryItem: @@ -134,32 +134,6 @@ class LibraryViewSet(viewsets.ModelViewSet): ) return Response(status=c.HTTP_400_BAD_REQUEST) - @extend_schema( - summary='claim item', - tags=['Library'], - request=None, - responses={ - c.HTTP_200_OK: s.LibraryItemSerializer, - c.HTTP_403_FORBIDDEN: None, - c.HTTP_404_NOT_FOUND: None - } - ) - @transaction.atomic - @action(detail=True, methods=['post']) - 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=c.HTTP_304_NOT_MODIFIED) - else: - item.owner = cast(m.User, self.request.user) - item.save() - m.Subscription.subscribe(user=item.owner, item=item) - return Response( - status=c.HTTP_200_OK, - data=s.LibraryItemSerializer(item).data - ) - @extend_schema( summary='subscribe to item', tags=['Library'], diff --git a/rsconcept/backend/apps/rsform/views/rsforms.py b/rsconcept/backend/apps/rsform/views/rsforms.py index c206828a..4e8ffb33 100644 --- a/rsconcept/backend/apps/rsform/views/rsforms.py +++ b/rsconcept/backend/apps/rsform/views/rsforms.py @@ -6,7 +6,7 @@ import pyconcept from django.db import transaction from django.http import HttpResponse from drf_spectacular.utils import extend_schema, extend_schema_view -from rest_framework import generics, permissions +from rest_framework import generics from rest_framework import status as c from rest_framework import views, viewsets from rest_framework.decorators import action, api_view @@ -15,6 +15,7 @@ from rest_framework.response import Response from .. import messages as msg from .. import models as m +from .. import permissions from .. import serializers as s from .. import utils @@ -33,9 +34,9 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr ''' Determine permission class. ''' if self.action in ['load_trs', 'cst_create', 'cst_delete_multiple', 'reset_aliases', 'cst_rename', 'cst_substitute']: - permission_list = [utils.ObjectOwnerOrAdmin] + permission_list = [permissions.ItemOwner] else: - permission_list = [permissions.AllowAny] + permission_list = [permissions.Anyone] return [permission() for permission in permission_list] @extend_schema( @@ -402,7 +403,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] + permission_classes = [permissions.GlobalUser] @extend_schema( summary='import TRS file into RSForm', diff --git a/rsconcept/backend/apps/rsform/views/versions.py b/rsconcept/backend/apps/rsform/views/versions.py index bf7e21d6..a5e69c9a 100644 --- a/rsconcept/backend/apps/rsform/views/versions.py +++ b/rsconcept/backend/apps/rsform/views/versions.py @@ -3,7 +3,7 @@ from typing import cast from django.http import HttpResponse from drf_spectacular.utils import extend_schema, extend_schema_view -from rest_framework import generics, permissions +from rest_framework import generics from rest_framework import status as c from rest_framework import viewsets from rest_framework.decorators import action, api_view, permission_classes @@ -11,25 +11,22 @@ from rest_framework.request import Request from rest_framework.response import Response from .. import models as m +from .. import permissions from .. import serializers as s from .. import utils @extend_schema(tags=['Version']) @extend_schema_view() -class VersionViewset(viewsets.GenericViewSet, generics.RetrieveUpdateDestroyAPIView): +class VersionViewset( + viewsets.GenericViewSet, + generics.RetrieveUpdateDestroyAPIView, + permissions.EditorMixin +): ''' Endpoint: Get / Update Constituenta. ''' queryset = m.Version.objects.all() serializer_class = s.VersionSerializer - def get_permissions(self): - result = super().get_permissions() - if self.request.method.upper() == 'GET': - result.append(permissions.AllowAny()) - else: - result.append(utils.ItemOwnerOrAdmin()) - return result - @extend_schema( summary='restore version data into current item', request=None, @@ -62,7 +59,7 @@ class VersionViewset(viewsets.GenericViewSet, generics.RetrieveUpdateDestroyAPIV } ) @api_view(['POST']) -@permission_classes([permissions.IsAuthenticated]) +@permission_classes([permissions.GlobalUser]) def create_version(request: Request, pk_item: int): ''' Endpoint: Create new version for RSForm copying current content. ''' try: diff --git a/rsconcept/frontend/src/app/backendAPI.ts b/rsconcept/frontend/src/app/backendAPI.ts index b739ad58..f78cb7d6 100644 --- a/rsconcept/frontend/src/app/backendAPI.ts +++ b/rsconcept/frontend/src/app/backendAPI.ts @@ -246,13 +246,6 @@ export function deleteLibraryItem(target: string, request: FrontAction) { }); } -export function postClaimLibraryItem(target: string, request: FrontPull) { - AxiosPost({ - endpoint: `/api/library/${target}/claim`, - request: request - }); -} - export function postSubscribe(target: string, request: FrontAction) { AxiosPost({ endpoint: `/api/library/${target}/subscribe`, diff --git a/rsconcept/frontend/src/context/RSFormContext.tsx b/rsconcept/frontend/src/context/RSFormContext.tsx index 72a31397..dff6d045 100644 --- a/rsconcept/frontend/src/context/RSFormContext.tsx +++ b/rsconcept/frontend/src/context/RSFormContext.tsx @@ -20,7 +20,6 @@ import { patchSubstituteConstituents, patchUploadTRS, patchVersion, - postClaimLibraryItem, postCreateVersion, postNewConstituenta, postSubscribe @@ -63,7 +62,6 @@ interface IRSFormContext { isSubscribed: boolean; update: (data: ILibraryUpdateData, callback?: DataCallback) => void; - claim: (callback?: DataCallback) => void; subscribe: (callback?: () => void) => void; unsubscribe: (callback?: () => void) => void; download: (callback: DataCallback) => void; @@ -180,29 +178,6 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps) [schemaID, setError, setSchema, schema, library] ); - const claim = useCallback( - (callback?: DataCallback) => { - if (!schema || !user) { - return; - } - setError(undefined); - postClaimLibraryItem(schemaID, { - showError: true, - setLoading: setProcessing, - onError: setError, - onSuccess: newData => { - setSchema(Object.assign(schema, newData)); - library.localUpdateItem(newData); - if (!user.subscriptions.includes(newData.id)) { - user.subscriptions.push(newData.id); - } - if (callback) callback(newData); - } - }); - }, - [schemaID, setError, schema, user, setSchema, library] - ); - const subscribe = useCallback( (callback?: () => void) => { if (!schema || !user) { @@ -543,7 +518,6 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps) update, download, upload, - claim, restoreOrder, resetAliases, produceStructure, diff --git a/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/RSFormToolbar.tsx b/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/RSFormToolbar.tsx index ae86452f..1a55ee95 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/RSFormToolbar.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/RSFormToolbar.tsx @@ -2,15 +2,7 @@ import { useMemo } from 'react'; -import { - IconDestroy, - IconDownload, - IconFollow, - IconFollowOff, - IconOwner, - IconSave, - IconShare -} from '@/components/Icons'; +import { IconDestroy, IconDownload, IconFollow, IconFollowOff, IconSave, IconShare } from '@/components/Icons'; import BadgeHelp from '@/components/info/BadgeHelp'; import MiniButton from '@/components/ui/MiniButton'; import Overlay from '@/components/ui/Overlay'; @@ -28,7 +20,7 @@ interface RSFormToolbarProps { onDestroy: () => void; } -function RSFormToolbar({ modified, anonymous, subscribed, claimable, onSubmit, onDestroy }: RSFormToolbarProps) { +function RSFormToolbar({ modified, anonymous, subscribed, onSubmit, onDestroy }: RSFormToolbarProps) { const controller = useRSEdit(); const canSave = useMemo(() => modified && !controller.isProcessing, [modified, controller.isProcessing]); return ( @@ -65,14 +57,6 @@ function RSFormToolbar({ modified, anonymous, subscribed, claimable, onSubmit, o onClick={controller.toggleSubscribe} /> ) : null} - {!anonymous && claimable ? ( - } - disabled={controller.isProcessing} - onClick={controller.claim} - /> - ) : null} {controller.isMutable ? ( void; promptClone: () => void; promptUpload: () => void; - claim: () => void; share: () => void; toggleSubscribe: () => void; download: () => void; @@ -488,13 +487,6 @@ export const RSEditState = ({ }); }, [model, isModified]); - const claim = useCallback(() => { - if (!window.confirm('Вы уверены, что хотите стать владельцем данной схемы?')) { - return; - } - model.claim(() => toast.success('Вы стали владельцем схемы')); - }, [model]); - const share = useCallback(() => { const currentRef = window.location.href; const url = currentRef.includes('?') ? currentRef + '&share' : currentRef + '?share'; @@ -547,7 +539,6 @@ export const RSEditState = ({ promptClone, promptUpload: () => setShowUpload(true), download, - claim, share, toggleSubscribe, diff --git a/rsconcept/frontend/src/pages/RSFormPage/RSTabsMenu.tsx b/rsconcept/frontend/src/pages/RSFormPage/RSTabsMenu.tsx index 777c80f1..564c8d88 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/RSTabsMenu.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/RSTabsMenu.tsx @@ -52,11 +52,6 @@ function RSTabsMenu({ onDestroy }: RSTabsMenuProps) { const editMenu = useDropdown(); const accessMenu = useDropdown(); - function handleClaimOwner() { - editMenu.hide(); - controller.claim(); - } - function handleDelete() { schemaMenu.hide(); onDestroy(); @@ -140,14 +135,6 @@ function RSTabsMenu({ onDestroy }: RSTabsMenuProps) { onClick={schemaMenu.toggle} /> - {user ? ( - } - disabled={!model.isClaimable && !model.isOwned} - onClick={!model.isOwned && model.isClaimable ? handleClaimOwner : undefined} - /> - ) : null} }