Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(organizations): add endpoints to handle organization members TASK-963 #5235

Merged
merged 24 commits into from
Nov 22, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e17f6a7
Add endpoints to handle organization members
rajpatel24 Nov 6, 2024
024f911
Merge branch 'main' of github.com:kobotoolbox/kpi into task-963-creat…
rajpatel24 Nov 7, 2024
0401fc4
Refactor organization member API to eliminate redundancy and optimize…
rajpatel24 Nov 13, 2024
1dec6eb
Merge branch 'main' into task-963-create-endpoints-to-handle-org-members
rajpatel24 Nov 13, 2024
3c174df
Add role-based validation tests for organization member permissions
rajpatel24 Nov 14, 2024
f432404
Merge branch 'main' into task-963-create-endpoints-to-handle-org-members
rajpatel24 Nov 14, 2024
d876fbc
Revert unintended change and fix linting issue
rajpatel24 Nov 14, 2024
953b716
Refactor organization members API with updated permission logic and c…
rajpatel24 Nov 15, 2024
f8cb95b
Refactor organization members API with updated permission logic and c…
rajpatel24 Nov 15, 2024
3d0372a
Resolve merge conflicts
rajpatel24 Nov 15, 2024
906be3d
Fix failing tests
rajpatel24 Nov 15, 2024
d122576
Merge branch 'main' into task-963-create-endpoints-to-handle-org-members
rajpatel24 Nov 18, 2024
94b47cb
Merge branch 'main' into task-963-create-endpoints-to-handle-org-members
magicznyleszek Nov 19, 2024
01c189c
Update delete logic to remove user from user table along with organiz…
rajpatel24 Nov 19, 2024
89db1d1
Merge branch 'main' into task-963-create-endpoints-to-handle-org-members
magicznyleszek Nov 19, 2024
988b10b
Merge branch 'main' into task-963-create-endpoints-to-handle-org-members
magicznyleszek Nov 19, 2024
e5abfa0
Merge branch 'main' into task-963-create-endpoints-to-handle-org-members
magicznyleszek Nov 19, 2024
def1a7a
Refactor permissions to block external users from listing organizatio…
rajpatel24 Nov 20, 2024
c2cf803
Merge branch 'main' into task-963-create-endpoints-to-handle-org-members
magicznyleszek Nov 20, 2024
d706056
Refactor serializer to retrieve user name from extra details and enab…
rajpatel24 Nov 21, 2024
0e28408
feat(organizations): update organizations endpoint to block post and …
rajpatel24 Nov 21, 2024
4d67c50
Optimize organization members API by prefetching related user details…
rajpatel24 Nov 21, 2024
d2fa51d
Merge branch 'main' into task-963-create-endpoints-to-handle-org-members
rajpatel24 Nov 22, 2024
5c0ef26
Update organization members API doc
rajpatel24 Nov 22, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion kobo/apps/organizations/serializers.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -11,10 +15,56 @@


class OrganizationUserSerializer(serializers.ModelSerializer):
noliveleger marked this conversation as resolved.
Show resolved Hide resolved
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()
noliveleger marked this conversation as resolved.
Show resolved Hide resolved
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')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's the wrong field. You need to retrieve User.extra_details.data['name'] . BTW in that case, the field should be user__extra_details__name .

cc @jamesrkiger

user__email = serializers.ReadOnlyField(source='user.email')
is_active = serializers.ReadOnlyField(source='user.is_active')
noliveleger marked this conversation as resolved.
Show resolved Hide resolved

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, you need to get if the user has MFA enabled. (e.g. MfaMethod.objects.filter(user=obj, is_active=True).exists()). Ensure to optimize this for a list like you did for role ;-)


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):
Expand Down
80 changes: 80 additions & 0 deletions kobo/apps/organizations/tests/test_organization_members_api.py
Original file line number Diff line number Diff line change
@@ -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')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For future-proof validaiton, please add mmo_override=True
This can be simplied with

self.organization = baker.make(Organization, id='org_12345', mmo_override=True)
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.add_user(self.owner_user)  # first user added to org, is always the owner and an admin
self.organization.add_user(self.member_user, is_admin=False) 

self.owner_user = baker.make(User, username='owner')
self.member_user = baker.make(User, username='member')
self.invited_user = baker.make(User, username='invited')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

invited_user is not used at all for the moment, right? Let's remove until we implement invitations.


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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All your tests are done with self.client == self.owner , but we need to validate permissions for regular members, admins and anonymous. Let's those tests as soon as #5218 is merged. I will tag this PR blocked by

Copy link
Contributor Author

@rajpatel24 rajpatel24 Nov 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing that out! I've added the role-based validation tests for organization member permissions.

One thing I still need to figure out is that I have added a new permission class and tried to use obj to retrieve the user role using the optimized approach I wrote in get_queryset(). It works fine when I test the endpoints in Postman or using a curl. However, for the tests, I'm unable to fetch the actual role, and it defaults to the member role. Do you have any suggestions for this?


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)
188 changes: 183 additions & 5 deletions kobo/apps/organizations/views.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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.

<pre class="prettyprint">
<b>GET</b> /api/v2/organizations/{organization_id}/members/
</pre>

> 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": "[email protected]",
> "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": "[email protected]",
> "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.

<pre class="prettyprint">
<b>GET</b> /api/v2/organizations/{organization_id}/members/{username}/
</pre>

> 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": "[email protected]",
> "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`.

<pre class="prettyprint">
<b>PATCH</b> /api/v2/organizations/{organization_id}/members/{username}/
</pre>

#### 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.

<pre class="prettyprint">
<b>DELETE</b> /api/v2/organizations/{organization_id}/members/{username}/
</pre>

> 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)
noliveleger marked this conversation as resolved.
Show resolved Hide resolved
9 changes: 8 additions & 1 deletion kpi/paginators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -142,3 +142,10 @@ class TinyPaginated(PageNumberPagination):
Same as Paginated with a small page size
"""
page_size = 50


class OrganizationPagination(PageNumberPagination):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would rename the class just to match what it is paginating (i.e.: organization members)

"""
Pagination class for Organization
"""
page_size = 10
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is 10 a requirement?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

10 is not a requirement defined in the task. I need to check with you or the team to determine the appropriate value for it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rajpatel24 Can we change this to limit offset pagination? Our table components on the frontend currently assume limit offset pagination and @noliveleger said it shouldn't cause any problems to use that here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jamesrkiger I have removed the custom pagination class for the organization members API. It will now use LimitOffsetPagination by default.

Loading
Loading