-
-
Notifications
You must be signed in to change notification settings - Fork 186
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(organizations): add endpoints to handle organization members TAS…
…K-963 (#5235) ### 📣 Summary - Enhance the organization member management API to support role updates and member removal via PATCH and DELETE requests, excluding member creation. ### 📖 Description - This update introduces endpoints for managing organization members: - GET `/api/v2/organizations/<org-id>/members/` to list all members in an organization. - PATCH `/api/v2/organizations/<org-id>/members/<username>/` to update a member's role (e.g., promote to admin). - DELETE `/api/v2/organizations/<org-id>/members/<username>/` to remove a member from the organization. Note: Creating members is not supported via this endpoint. Roles are restricted to 'admin' or 'member'. Including the details of invited members (those who have not yet joined the organization) is not covered in this update. ### 👷 Description for instance maintainers - This update provides new functionality to manage organization members: - Role updates for members (promotion/demotion between 'admin' and 'member'). - Member removal from an organization. - The endpoints are optimized to use database joins to fetch member roles efficiently without excessive database queries, ensuring minimal load. ### 👀 Preview steps 1. ℹ️ Have an account and an organization with multiple members. 2. Use the GET method to list the members of the organization. 3. Use the PATCH method to update a member's role (e.g., promote a user to admin). 4. Use the DELETE method to remove a member from the organization. 5. 🟢 Notice the endpoints behave as expected, with valid role updates and member removal. ### 💭 Notes - The code uses database joins to optimize role determination for members, avoiding inefficient role lookups. - Role validation restricts role assignments to 'admin' or 'member' only. - Including invited members' details is not covered in this update. This will be implemented in a future update.
- Loading branch information
1 parent
6729e75
commit 79e33e8
Showing
6 changed files
with
421 additions
and
28 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
131 changes: 131 additions & 0 deletions
131
kobo/apps/organizations/tests/test_organization_members_api.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,131 @@ | ||
from ddt import ddt, data, unpack | ||
from django.urls import reverse | ||
from rest_framework import status | ||
|
||
from kobo.apps.kobo_auth.shortcuts import User | ||
from kobo.apps.organizations.tests.test_organizations_api import ( | ||
BaseOrganizationAssetApiTestCase | ||
) | ||
from kpi.urls.router_api_v2 import URL_NAMESPACE | ||
|
||
|
||
@ddt | ||
class OrganizationMemberAPITestCase(BaseOrganizationAssetApiTestCase): | ||
fixtures = ['test_data'] | ||
URL_NAMESPACE = URL_NAMESPACE | ||
|
||
def setUp(self): | ||
super().setUp() | ||
self.organization = self.someuser.organization | ||
self.owner_user = self.someuser | ||
self.member_user = self.alice | ||
self.admin_user = self.anotheruser | ||
self.external_user = self.bob | ||
|
||
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 | ||
}, | ||
) | ||
|
||
@data( | ||
('owner', status.HTTP_200_OK), | ||
('admin', status.HTTP_200_OK), | ||
('member', status.HTTP_200_OK), | ||
('external', status.HTTP_404_NOT_FOUND), | ||
('anonymous', status.HTTP_401_UNAUTHORIZED), | ||
) | ||
@unpack | ||
def test_list_members_with_different_roles(self, user_role, expected_status): | ||
if user_role == 'anonymous': | ||
self.client.logout() | ||
else: | ||
user = getattr(self, f'{user_role}_user') | ||
self.client.force_login(user) | ||
response = self.client.get(self.list_url) | ||
self.assertEqual(response.status_code, expected_status) | ||
|
||
@data( | ||
('owner', status.HTTP_200_OK), | ||
('admin', status.HTTP_200_OK), | ||
('member', status.HTTP_200_OK), | ||
('external', status.HTTP_404_NOT_FOUND), | ||
('anonymous', status.HTTP_401_UNAUTHORIZED), | ||
) | ||
@unpack | ||
def test_retrieve_member_details_with_different_roles( | ||
self, user_role, expected_status | ||
): | ||
if user_role == 'anonymous': | ||
self.client.logout() | ||
else: | ||
user = getattr(self, f'{user_role}_user') | ||
self.client.force_login(user) | ||
response = self.client.get(self.detail_url(self.member_user)) | ||
self.assertEqual(response.status_code, expected_status) | ||
|
||
@data( | ||
('owner', status.HTTP_200_OK), | ||
('admin', status.HTTP_200_OK), | ||
('member', status.HTTP_403_FORBIDDEN), | ||
('external', status.HTTP_404_NOT_FOUND), | ||
('anonymous', status.HTTP_401_UNAUTHORIZED), | ||
) | ||
@unpack | ||
def test_update_member_role_with_different_roles(self, user_role, expected_status): | ||
if user_role == 'anonymous': | ||
self.client.logout() | ||
else: | ||
user = getattr(self, f'{user_role}_user') | ||
self.client.force_login(user) | ||
data = {'role': 'admin'} | ||
response = self.client.patch(self.detail_url(self.member_user), data) | ||
self.assertEqual(response.status_code, expected_status) | ||
|
||
@data( | ||
('owner', status.HTTP_204_NO_CONTENT), | ||
('admin', status.HTTP_204_NO_CONTENT), | ||
('member', status.HTTP_403_FORBIDDEN), | ||
('external', status.HTTP_404_NOT_FOUND), | ||
('anonymous', status.HTTP_401_UNAUTHORIZED), | ||
) | ||
@unpack | ||
def test_delete_member_with_different_roles(self, user_role, expected_status): | ||
if user_role == 'anonymous': | ||
self.client.logout() | ||
else: | ||
user = getattr(self, f'{user_role}_user') | ||
self.client.force_login(user) | ||
response = self.client.delete(self.detail_url(self.member_user)) | ||
self.assertEqual(response.status_code, expected_status) | ||
if expected_status == status.HTTP_204_NO_CONTENT: | ||
# Confirm deletion | ||
response = self.client.get(self.detail_url(self.member_user)) | ||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) | ||
self.assertFalse( | ||
User.objects.filter(username=f'{user_role}_user').exists() | ||
) | ||
|
||
@data( | ||
('owner', status.HTTP_405_METHOD_NOT_ALLOWED), | ||
('admin', status.HTTP_405_METHOD_NOT_ALLOWED), | ||
('member', status.HTTP_403_FORBIDDEN), | ||
('external', status.HTTP_404_NOT_FOUND), | ||
('anonymous', status.HTTP_401_UNAUTHORIZED), | ||
) | ||
@unpack | ||
def test_post_request_is_not_allowed(self, user_role, expected_status): | ||
if user_role == 'anonymous': | ||
self.client.logout() | ||
else: | ||
user = getattr(self, f'{user_role}_user') | ||
self.client.force_login(user) | ||
data = {'role': 'admin'} | ||
response = self.client.post(self.list_url, data) | ||
self.assertEqual(response.status_code, expected_status) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.