diff --git a/kobo/apps/organizations/serializers.py b/kobo/apps/organizations/serializers.py index 21d73e131d..25dd405c30 100644 --- a/kobo/apps/organizations/serializers.py +++ b/kobo/apps/organizations/serializers.py @@ -1,4 +1,8 @@ +from constance import config +from django.contrib.auth import get_user_model +from django.utils.translation import gettext as t from rest_framework import serializers +from rest_framework.reverse import reverse from kobo.apps.organizations.models import ( create_organization, @@ -11,10 +15,56 @@ class OrganizationUserSerializer(serializers.ModelSerializer): + user = serializers.HyperlinkedRelatedField( + queryset=get_user_model().objects.all(), + lookup_field='username', + view_name='user-kpi-detail', + ) + role = serializers.CharField() + has_mfa_enabled = serializers.SerializerMethodField() + url = serializers.SerializerMethodField() + date_joined = serializers.DateTimeField( + source='user.date_joined', format='%Y-%m-%dT%H:%M:%SZ' + ) + user__username = serializers.ReadOnlyField(source='user.username') + user__name = serializers.ReadOnlyField(source='user.get_full_name') + user__email = serializers.ReadOnlyField(source='user.email') + is_active = serializers.ReadOnlyField(source='user.is_active') class Meta: model = OrganizationUser - fields = ['user', 'organization'] + fields = [ + 'url', + 'user', + 'user__username', + 'user__email', + 'user__name', + 'role', + 'has_mfa_enabled', + 'date_joined', + 'is_active' + ] + + def get_has_mfa_enabled(self, obj): + return config.MFA_ENABLED + + def get_url(self, obj): + request = self.context.get('request') + return reverse( + 'organization-members-detail', + kwargs={ + 'organization_id': obj.organization.id, + 'user__username': obj.user.username + }, + request=request + ) + + def validate_role(self, role): + if role not in ['admin', 'member']: + raise serializers.ValidationError( + {'role': t("Invalid role. Only 'admin' or 'member' are allowed")} + ) + return role class OrganizationOwnerSerializer(serializers.ModelSerializer): diff --git a/kobo/apps/organizations/tests/test_organization_members_api.py b/kobo/apps/organizations/tests/test_organization_members_api.py new file mode 100644 index 0000000000..7d0045be6a --- /dev/null +++ b/kobo/apps/organizations/tests/test_organization_members_api.py @@ -0,0 +1,80 @@ +from django.urls import reverse +from model_bakery import baker +from rest_framework import status + +from kobo.apps.kobo_auth.shortcuts import User +from kobo.apps.organizations.models import Organization, OrganizationUser +from kpi.tests.kpi_test_case import BaseTestCase +from kpi.urls.router_api_v2 import URL_NAMESPACE + + +class OrganizationMemberAPITestCase(BaseTestCase): + fixtures = ['test_data'] + URL_NAMESPACE = URL_NAMESPACE + + def setUp(self): + self.organization = baker.make(Organization, id='org_12345') + self.owner_user = baker.make(User, username='owner') + self.member_user = baker.make(User, username='member') + self.invited_user = baker.make(User, username='invited') + + self.organization_user_owner = baker.make( + OrganizationUser, + organization=self.organization, + user=self.owner_user, + is_admin=True, + ) + self.organization_user_member = baker.make( + OrganizationUser, + organization=self.organization, + user=self.member_user + ) + + self.client.force_login(self.owner_user) + self.list_url = reverse( + self._get_endpoint('organization-members-list'), + kwargs={'organization_id': self.organization.id}, + ) + self.detail_url = lambda username: reverse( + self._get_endpoint('organization-members-detail'), + kwargs={ + 'organization_id': self.organization.id, + 'user__username': username + }, + ) + + def test_list_members(self): + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn( + 'owner', + [member['user__username'] for member in response.data.get('results')] + ) + self.assertIn( + 'member', + [member['user__username'] for member in response.data.get('results')] + ) + + def test_retrieve_member_details(self): + response = self.client.get(self.detail_url('member')) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['user__username'], 'member') + self.assertEqual(response.data['role'], 'member') + + def test_update_member_role(self): + data = {'role': 'admin'} + response = self.client.patch(self.detail_url('member'), data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['role'], 'admin') + + def test_delete_member(self): + response = self.client.delete(self.detail_url('member')) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + # Confirm deletion + response = self.client.get(self.detail_url('member')) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_list_requires_authentication(self): + self.client.logout() + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/kobo/apps/organizations/views.py b/kobo/apps/organizations/views.py index 7102f513bd..7792d1a1ee 100644 --- a/kobo/apps/organizations/views.py +++ b/kobo/apps/organizations/views.py @@ -1,26 +1,34 @@ from django.conf import settings from django.contrib.postgres.aggregates import ArrayAgg -from django.db.models import QuerySet +from django.db.models import ( + QuerySet, + Case, + When, + Value, + CharField, + OuterRef, +) +from django.db.models.expressions import Exists from django.utils.decorators import method_decorator from django.views.decorators.cache import cache_page from django_dont_vary_on.decorators import only_vary_on -from kpi import filters from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response +from kpi import filters from kpi.constants import ASSET_TYPE_SURVEY from kpi.models.asset import Asset -from kpi.paginators import AssetUsagePagination +from kpi.paginators import AssetUsagePagination, OrganizationPagination from kpi.permissions import IsAuthenticated from kpi.serializers.v2.service_usage import ( CustomAssetUsageSerializer, ServiceUsageSerializer, ) from kpi.utils.object_permission import get_database_user -from .models import Organization +from .models import Organization, OrganizationOwner, OrganizationUser from .permissions import IsOrgAdminOrReadOnly -from .serializers import OrganizationSerializer +from .serializers import OrganizationSerializer, OrganizationUserSerializer from ..stripe.constants import ACTIVE_STRIPE_STATUSES @@ -194,3 +202,173 @@ def asset_usage(self, request, pk=None, *args, **kwargs): page, many=True, context=context ) return self.get_paginated_response(serializer.data) + + +class OrganizationMemberViewSet(viewsets.ModelViewSet): + """ + * Manage organization members and their roles within an organization. + * Run a partial update on an organization member to promote or demote. + + ## Organization Members API + + This API allows authorized users to view and manage the members of an + organization, including their roles. It handles existing members. It also + allows updating roles, such as promoting a member to an admin or assigning + a new owner. + + ### List Members + + Retrieves all members in the specified organization. + +
+    GET /api/v2/organizations/{organization_id}/members/
+    
+ + > Example + > + > curl -X GET https://[kpi]/api/v2/organizations/org_12345/members/ + + > Response 200 + + > { + > "count": 2, + > "next": null, + > "previous": null, + > "results": [ + > { + > "url": "http://[kpi]/api/v2/organizations/org_12345/ \ + > members/foo_bar/", + > "user": "http://[kpi]/api/v2/users/foo_bar/", + > "user__username": "foo_bar", + > "user__email": "foo_bar@example.com", + > "user__name": "Foo Bar", + > "role": "owner", + > "has_mfa_enabled": true, + > "date_joined": "2024-08-11T12:36:32Z", + > "is_active": true + > }, + > { + > "url": "http://[kpi]/api/v2/organizations/org_12345/ \ + > members/john_doe/", + > "user": "http://[kpi]/api/v2/users/john_doe/", + > "user__username": "john_doe", + > "user__email": "john_doe@example.com", + > "user__name": "John Doe", + > "role": "admin", + > "has_mfa_enabled": false, + > "date_joined": "2024-10-21T06:38:45Z", + > "is_active": true + > } + > ] + > } + + The response includes detailed information about each member, such as their + username, email, role (owner, admin, member), and account status. + + ### Retrieve Member Details + + Retrieves the details of a specific member within an organization by username. + +
+    GET /api/v2/organizations/{organization_id}/members/{username}/
+    
+ + > Example + > + > curl -X GET https://[kpi]/api/v2/organizations/org_12345/members/foo_bar/ + + > Response 200 + + > { + > "url": "http://[kpi]/api/v2/organizations/org_12345/members/foo_bar/", + > "user": "http://[kpi]/api/v2/users/foo_bar/", + > "user__username": "foo_bar", + > "user__email": "foo_bar@example.com", + > "user__name": "Foo Bar", + > "role": "owner", + > "has_mfa_enabled": true, + > "date_joined": "2024-08-11T12:36:32Z", + > "is_active": true + > } + + ### Update Member Role + + Updates the role of a member within the organization to `owner`, `admin`, or + `member`. + +
+    PATCH /api/v2/organizations/{organization_id}/members/{username}/
+    
+ + #### Payload + > { + > "role": "admin" + > } + + - **admin**: Grants the member admin privileges within the organization + - **member**: Revokes admin privileges, setting the member as a regular user + + > Example + > + > curl -X PATCH https://[kpi]/api/v2/organizations/org_12345/ \ + > members/demo_user/ -d '{"role": "admin"}' + + ### Remove Member + + Removes a member from the organization. + +
+    DELETE /api/v2/organizations/{organization_id}/members/{username}/
+    
+ + > Example + > + > curl -X DELETE https://[kpi]/api/v2/organizations/org_12345/members/foo_bar/ + + ## Permissions + + - The user must be authenticated to perform these actions. + + ## Notes + + - **Role Validation**: Only valid roles ('admin', 'member') are accepted + in updates. + """ + serializer_class = OrganizationUserSerializer + permission_classes = [IsAuthenticated] + pagination_class = OrganizationPagination + lookup_field = 'user__username' + + def get_queryset(self): + organization_id = self.kwargs['organization_id'] + + # Subquery to check if the user is the owner + owner_subquery = OrganizationOwner.objects.filter( + organization_id=organization_id, + organization_user=OuterRef('pk') + ).values('pk') + + # Annotate with role based on organization ownership and admin status + queryset = OrganizationUser.objects.filter( + organization_id=organization_id + ).annotate( + role=Case( + When(Exists(owner_subquery), then=Value('owner')), + When(is_admin=True, then=Value('admin')), + default=Value('member'), + output_field=CharField() + ) + ) + return queryset + + def partial_update(self, request, *args, **kwargs): + instance = self.get_object() + serializer = self.get_serializer( + instance, data=request.data, partial=True + ) + serializer.is_valid(raise_exception=True) + role = serializer.validated_data.get('role') + if role: + instance.is_admin = (role == 'admin') + instance.save() + return super().partial_update(request, *args, **kwargs) diff --git a/kpi/paginators.py b/kpi/paginators.py index 4b0b9ea667..33f29da90f 100644 --- a/kpi/paginators.py +++ b/kpi/paginators.py @@ -121,7 +121,7 @@ class DataPagination(LimitOffsetPagination): offset_query_param = 'start' max_limit = settings.SUBMISSION_LIST_LIMIT - + class FastAssetPagination(Paginated): """ Pagination class optimized for faster counting for DISTINCT queries on large tables. @@ -142,3 +142,10 @@ class TinyPaginated(PageNumberPagination): Same as Paginated with a small page size """ page_size = 50 + + +class OrganizationPagination(PageNumberPagination): + """ + Pagination class for Organization + """ + page_size = 10 diff --git a/kpi/urls/router_api_v2.py b/kpi/urls/router_api_v2.py index 7c7dbd2575..411d77de2b 100644 --- a/kpi/urls/router_api_v2.py +++ b/kpi/urls/router_api_v2.py @@ -6,7 +6,7 @@ from kobo.apps.hook.views.v2.hook import HookViewSet from kobo.apps.hook.views.v2.hook_log import HookLogViewSet from kobo.apps.languages.urls import router as language_router -from kobo.apps.organizations.views import OrganizationViewSet +from kobo.apps.organizations.views import OrganizationViewSet, OrganizationMemberViewSet from kobo.apps.project_ownership.urls import router as project_ownership_router from kobo.apps.project_views.views import ProjectViewViewSet from kpi.views.v2.asset import AssetViewSet @@ -140,6 +140,12 @@ def get_urls(self, *args, **kwargs): router_api_v2.register(r'imports', ImportTaskViewSet) router_api_v2.register(r'organizations', OrganizationViewSet, basename='organizations',) +router_api_v2.register( + r'organizations/(?P[^/.]+)/members', + OrganizationMemberViewSet, + basename='organization-members', +) + router_api_v2.register(r'permissions', PermissionViewSet) router_api_v2.register(r'project-views', ProjectViewViewSet) router_api_v2.register(r'service_usage',