Skip to content

Commit

Permalink
Add endpoints to handle organization members
Browse files Browse the repository at this point in the history
  • Loading branch information
rajpatel24 committed Nov 6, 2024
1 parent 6d239d8 commit e17f6a7
Show file tree
Hide file tree
Showing 5 changed files with 329 additions and 8 deletions.
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):
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):
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')
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)
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)
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):
"""
Pagination class for Organization
"""
page_size = 10
Loading

0 comments on commit e17f6a7

Please sign in to comment.