From f621189bf05cca32839eae077f1c2d9e9f20c6f4 Mon Sep 17 00:00:00 2001 From: Valentin Rigal <rigal@teklia.com> Date: Tue, 24 Nov 2020 11:29:51 +0000 Subject: [PATCH] Endpoint to retrieve, update, delete a group --- arkindex/project/api_v1.py | 2 + arkindex/project/openapi/patch.yml | 13 +++ arkindex/users/api.py | 40 ++++++++- arkindex/users/tests/test_membership.py | 110 ++++++++++++++++++++++-- 4 files changed, 155 insertions(+), 10 deletions(-) diff --git a/arkindex/project/api_v1.py b/arkindex/project/api_v1.py index 352d09dfbe..8925459467 100644 --- a/arkindex/project/api_v1.py +++ b/arkindex/project/api_v1.py @@ -86,6 +86,7 @@ from arkindex.project.openapi import SchemaGenerator from arkindex.users.api import ( CredentialsList, CredentialsRetrieve, + GroupDetails, GroupMembershipsList, GroupsList, JobList, @@ -249,6 +250,7 @@ api = [ # Rights management path('groups/', GroupsList.as_view(), name='groups-list'), + path('group/<uuid:pk>/', GroupDetails.as_view(), name='group-details'), path('group/<uuid:pk>/members/', GroupMembershipsList.as_view(), name='group-members'), # Asynchronous jobs diff --git a/arkindex/project/openapi/patch.yml b/arkindex/project/openapi/patch.yml index 7f37c6b43f..f7aa43195f 100644 --- a/arkindex/project/openapi/patch.yml +++ b/arkindex/project/openapi/patch.yml @@ -451,3 +451,16 @@ paths: post: operationId: CreateGroup description: Create a new group. The request user will be added as a member of this group. + /api/v1/group/{id}/: + patch: + description: >- + Partially update details of a group. + Requires to have `admin` privileges on this group. + put: + description: >- + Update details of a group. + Requires to have `admin` privileges on this group. + delete: + description: >- + Delete a group. + Requires to have `admin` privileges on this group. diff --git a/arkindex/users/api.py b/arkindex/users/api.py index 9e3710be3e..6d281cbfe8 100644 --- a/arkindex/users/api.py +++ b/arkindex/users/api.py @@ -26,13 +26,14 @@ from rest_framework.generics import ( RetrieveUpdateDestroyAPIView, UpdateAPIView, ) +from rest_framework.permissions import SAFE_METHODS from rest_framework.response import Response from rest_framework.views import APIView from rq.job import JobStatus from arkindex.documents.models import Corpus from arkindex.project.permissions import IsAuthenticatedOrReadOnly, IsVerified -from arkindex.users.models import Group, OAuthStatus, Scope, User, UserScope +from arkindex.users.models import Group, LevelRight, OAuthStatus, Scope, User, UserScope from arkindex.users.providers import get_provider, oauth_providers from arkindex.users.serializers import ( EmailLoginSerializer, @@ -465,7 +466,7 @@ class GroupsList(ListCreateAPIView): return Group.objects \ .annotate(members_count=Count('users')) \ .filter(Q(public=True) | Q(users__in=[self.request.user])) \ - .order_by('id') + .order_by('name', 'id') class JobList(ListAPIView): @@ -523,3 +524,38 @@ class GroupMembershipsList(ListAPIView): id=self.kwargs['pk'] )) return group.memberships.order_by('user__display_name', 'id').prefetch_related('user') + + +class GroupDetails(RetrieveUpdateDestroyAPIView): + """ + Retrieve details about a specific group + """ + serializer_class = GroupSerializer + permission_classes = (IsVerified, ) + openapi_overrides = { + 'security': [], + 'tags': ['users'], + } + + def get_object(self): + if not hasattr(self, '_group'): + self._group = super().get_object() + return self._group + + def check_object_permissions(self, request, obj): + if not hasattr(self, '_member'): + self._member = obj.memberships.get(user=self.request.user) + # Check the user has the right to delete or update a group + super().check_object_permissions(request, obj) + user_level = LevelRight.from_level(self._member.level) + if self.request.method not in SAFE_METHODS and user_level is not LevelRight.Admin: + raise self.permission_denied( + request, + message='Only members with an admin privilege may update or delete this group.' + ) + + def get_queryset(self): + # Filter groups to which user belongs only + return Group.objects\ + .annotate(members_count=Count('memberships')) \ + .filter(Q(users__in=[self.request.user])) diff --git a/arkindex/users/tests/test_membership.py b/arkindex/users/tests/test_membership.py index 95adf48159..9a3890fbc3 100644 --- a/arkindex/users/tests/test_membership.py +++ b/arkindex/users/tests/test_membership.py @@ -2,7 +2,7 @@ from django.urls import reverse from rest_framework import status from arkindex.project.tests import FixtureAPITestCase -from arkindex.users.models import Group, User +from arkindex.users.models import Group, LevelRight, User class TestMembership(FixtureAPITestCase): @@ -17,7 +17,7 @@ class TestMembership(FixtureAPITestCase): cls.admin = User.objects.create_superuser('admin@address.com', 'P4$5w0Rd') cls.admin_group = cls.admin.groups.create(name="Admin group", public=True) - cls.admin_group.memberships.update(level=100) + cls.admin_group.memberships.update(level=LevelRight.Admin.level) def test_verified_user_group_create(self): """ @@ -61,7 +61,7 @@ class TestMembership(FixtureAPITestCase): }) self.assertCountEqual( group.memberships.values_list('user', 'level'), - [(self.user.id, 100)] + [(self.user.id, LevelRight.Admin.level)] ) def test_crate_group_duplicated_name(self): @@ -87,15 +87,15 @@ class TestMembership(FixtureAPITestCase): 'previous': None, 'results': [ { - 'id': str(self.group.id), - 'members_count': 3, - 'name': 'User group', - 'public': False - }, { 'id': str(self.admin_group.id), 'members_count': 1, 'name': 'Admin group', 'public': True + }, { + 'id': str(self.group.id), + 'members_count': 3, + 'name': 'User group', + 'public': False } ] }) @@ -168,3 +168,97 @@ class TestMembership(FixtureAPITestCase): } ] }) + + def test_retrieve_group_no_member(self): + """ + A non memeber of a group may not retrieve its details, even if it is public + """ + self.client.force_login(self.user) + with self.assertNumQueries(3): + response = self.client.get(reverse('api:group-details', kwargs={'pk': str(self.admin_group.id)})) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_retrieve_group_details(self): + self.client.force_login(self.user) + with self.assertNumQueries(4): + response = self.client.get(reverse('api:group-details', kwargs={'pk': str(self.group.id)})) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(response.json(), { + 'id': str(self.group.id), + 'members_count': 3, + 'name': self.group.name, + 'public': self.group.public + }) + + def test_update_group_no_admin(self): + """ + User must be a group administrator to edit its properties + """ + non_admin = User.objects.filter( + memberships__group=self.group, + memberships__level__lt=LevelRight.Admin.level + ).first() + self.client.force_login(non_admin) + with self.assertNumQueries(2): + response = self.client.patch(reverse('api:group-details', kwargs={'pk': str(self.group.id)}), {'name': 'A'}) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertDictEqual(response.json(), { + 'detail': 'You do not have permission to perform this action.' + }) + + def test_update_group(self): + """ + Group name and public attributes may be updated by an admin member + """ + payload = { + 'name': 'Renamed group', + 'public': True, + 'id': 1337, + 'members_count': 42 + } + self.client.force_login(self.user) + with self.assertNumQueries(5): + response = self.client.put( + reverse('api:group-details', kwargs={'pk': str(self.group.id)}), + payload + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(response.json(), { + 'id': str(self.group.id), + 'members_count': 3, + 'name': 'Renamed group', + 'public': True + }) + + def test_delete_group_no_admin(self): + non_admin = User.objects.filter( + memberships__group=self.group, + memberships__level__lt=LevelRight.Admin.level + ).first() + non_admin.verified_email = True + non_admin.save() + self.client.force_login(non_admin) + with self.assertNumQueries(4): + response = self.client.delete(reverse('api:group-details', kwargs={'pk': str(self.group.id)})) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertDictEqual(response.json(), { + 'detail': 'Only members with an admin privilege may update or delete this group.' + }) + + def test_delete_group(self): + """ + A group admin is allowed to delete the group + """ + group = Group.objects.create(name='Another group') + user = group.users.create( + email='user42@test.test', + verified_email=True, + through_defaults={'level': LevelRight.Admin.level} + ) + self.client.force_login(user) + with self.assertNumQueries(6): + response = self.client.delete(reverse('api:group-details', kwargs={'pk': str(group.id)})) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + with self.assertRaises(Group.DoesNotExist): + group.refresh_from_db() + self.assertTrue(User.objects.filter(id=user.id).exists()) -- GitLab