From 64a2b55e760f67a48b681026c4e0705e6cd5ee09 Mon Sep 17 00:00:00 2001 From: Raj Patel Date: Fri, 20 Dec 2024 20:27:36 +0530 Subject: [PATCH] Create endpoints to handle organization invitations --- kobo/apps/organizations/constants.py | 18 ++ ...status_field_to_organization_invitation.py | 32 +++ kobo/apps/organizations/models.py | 95 ++++++- kobo/apps/organizations/serializers.py | 248 +++++++++++++++++- kobo/apps/organizations/tasks.py | 6 +- .../templates/emails/accepted_invite.html | 12 + .../templates/emails/accepted_invite.txt | 10 + .../templates/emails/declined_invite.html | 7 + .../templates/emails/declined_invite.txt | 6 + .../templates/emails/new_invite.html | 19 ++ .../templates/emails/new_invite.txt | 15 ++ .../tests/test_organization_invitations.py | 121 +++++++++ kobo/apps/organizations/views.py | 130 ++++++++- kpi/urls/router_api_v2.py | 11 +- 14 files changed, 722 insertions(+), 8 deletions(-) create mode 100644 kobo/apps/organizations/migrations/0010_add_status_field_to_organization_invitation.py create mode 100644 kobo/apps/organizations/templates/emails/accepted_invite.html create mode 100644 kobo/apps/organizations/templates/emails/accepted_invite.txt create mode 100644 kobo/apps/organizations/templates/emails/declined_invite.html create mode 100644 kobo/apps/organizations/templates/emails/declined_invite.txt create mode 100644 kobo/apps/organizations/templates/emails/new_invite.html create mode 100644 kobo/apps/organizations/templates/emails/new_invite.txt create mode 100644 kobo/apps/organizations/tests/test_organization_invitations.py diff --git a/kobo/apps/organizations/constants.py b/kobo/apps/organizations/constants.py index 790f5984f5..907d0f35b0 100644 --- a/kobo/apps/organizations/constants.py +++ b/kobo/apps/organizations/constants.py @@ -1,4 +1,22 @@ +INVITATION_OWNER_ERROR = ( + 'This account is already the owner of {organization_name}. ' + 'You cannot join multiple organizations with the same account. ' + 'To accept this invitation, you must either transfer ownership of ' + '{organization_name} to a different account or sign in using a different ' + 'account with the same email address. If you do not already have another ' + 'account, you can create one.' +) + +INVITATION_MEMBER_ERROR = ( + 'This account is already a member in {organization_name}. ' + 'You cannot join multiple organizations with the same account. ' + 'To accept this invitation, sign in using a different account with the ' + 'same email address. If you do not already have another account, you can ' + 'create one.' +) ORG_ADMIN_ROLE = 'admin' ORG_EXTERNAL_ROLE = 'external' ORG_MEMBER_ROLE = 'member' ORG_OWNER_ROLE = 'owner' +USER_DOES_NOT_EXIST_ERROR = \ + 'User with username or email {invitee} does not exist or is not active.' diff --git a/kobo/apps/organizations/migrations/0010_add_status_field_to_organization_invitation.py b/kobo/apps/organizations/migrations/0010_add_status_field_to_organization_invitation.py new file mode 100644 index 0000000000..7fc44dd325 --- /dev/null +++ b/kobo/apps/organizations/migrations/0010_add_status_field_to_organization_invitation.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.15 on 2024-12-20 14:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('organizations', '0009_update_db_state_with_auth_user'), + ] + + operations = [ + migrations.AddField( + model_name='organizationinvitation', + name='status', + field=models.CharField( + choices=[ + ('accepted', 'Accepted'), + ('cancelled', 'Cancelled'), + ('complete', 'Complete'), + ('declined', 'Declined'), + ('expired', 'Expired'), + ('failed', 'Failed'), + ('in_progress', 'In Progress'), + ('pending', 'Pending'), + ('resent', 'Resent'), + ], + default='pending', + max_length=11, + ), + ), + ] diff --git a/kobo/apps/organizations/models.py b/kobo/apps/organizations/models.py index 4c7ad5ef63..45d291ce23 100644 --- a/kobo/apps/organizations/models.py +++ b/kobo/apps/organizations/models.py @@ -24,6 +24,7 @@ from organizations.utils import create_organization as create_organization_base from kpi.fields import KpiUidField +from kpi.utils.mailer import EmailMessage, Mailer from .constants import ( ORG_ADMIN_ROLE, @@ -46,6 +47,19 @@ class OrganizationType(models.TextChoices): NONE = 'none', t('I am not associated with any organization') +class OrganizationInviteStatusChoices(models.TextChoices): + + ACCEPTED = 'accepted' + CANCELLED = 'cancelled' + COMPLETE = 'complete' + DECLINED = 'declined' + EXPIRED = 'expired' + FAILED = 'failed' + IN_PROGRESS = 'in_progress' + PENDING = 'pending' + RESENT = 'resent' + + class Organization(AbstractOrganization): id = KpiUidField(uid_prefix='org', primary_key=True) mmo_override = models.BooleanField( @@ -273,7 +287,86 @@ class OrganizationOwner(AbstractOrganizationOwner): class OrganizationInvitation(AbstractOrganizationInvitation): - pass + status = models.CharField( + max_length=11, + choices=OrganizationInviteStatusChoices.choices, + default=OrganizationInviteStatusChoices.PENDING, + ) + + def send_acceptance_email(self): + + template_variables = { + 'sender_username': self.invited_by.username, + 'sender_email': self.invited_by.email, + 'recipient_username': self.invitee.username, + 'recipient_email': self.invitee.email, + 'organization_name': self.invited_by.organization.name, + 'base_url': settings.KOBOFORM_URL, + } + + email_message = EmailMessage( + to=self.invited_by.email, + subject=t('KoboToolbox organization invitation accepted'), + plain_text_content_or_template='emails/accepted_invite.txt', + template_variables=template_variables, + html_content_or_template='emails/accepted_invite.html', + language=self.invitee.extra_details.data.get('last_ui_language'), + ) + + Mailer.send(email_message) + + def send_invite_email(self): + """ + Sends an email to invite a user to join a team as an admin. + """ + template_variables = { + 'sender_username': self.invited_by.username, + 'sender_email': self.invited_by.email, + 'recipient_username': ( + self.invitee.username + if self.invitee + else self.invitee_identifier + ), + 'organization_name': self.invited_by.organization.name, + 'base_url': settings.KOBOFORM_URL, + 'invite_uid': self.guid, + } + + email_message = EmailMessage( + to=self.invitee.email if self.invitee else self.invitee_identifier, + subject='Invitation to Join the Organization', + plain_text_content_or_template='emails/new_invite.txt', + template_variables=template_variables, + html_content_or_template='emails/new_invite.html', + language=( + self.invitee.extra_details.data.get('last_ui_language') + if self.invitee + else 'en' + ), + ) + + Mailer.send(email_message) + + def send_refusal_email(self): + template_variables = { + 'sender_username': self.invited_by.username, + 'sender_email': self.invited_by.email, + 'recipient_username': self.invitee.username, + 'recipient_email': self.invitee.email, + 'organization_name': self.invited_by.organization.name, + 'base_url': settings.KOBOFORM_URL, + } + + email_message = EmailMessage( + to=self.invited_by.email, + subject=t('KoboToolbox organization invitation declined'), + plain_text_content_or_template='emails/declined_invite.txt', + template_variables=template_variables, + html_content_or_template='emails/declined_invite.html', + language=self.invitee.extra_details.data.get('last_ui_language'), + ) + + Mailer.send(email_message) create_organization = partial(create_organization_base, model=Organization) diff --git a/kobo/apps/organizations/serializers.py b/kobo/apps/organizations/serializers.py index 8194f71118..012517ddc3 100644 --- a/kobo/apps/organizations/serializers.py +++ b/kobo/apps/organizations/serializers.py @@ -1,18 +1,30 @@ from django.contrib.auth import get_user_model +from django.core.validators import validate_email +from django.core.exceptions import ValidationError from django.utils.translation import gettext as t from rest_framework import serializers +from rest_framework.exceptions import PermissionDenied, NotFound from rest_framework.relations import HyperlinkedIdentityField from rest_framework.reverse import reverse from kobo.apps.organizations.models import ( + create_organization, Organization, OrganizationOwner, OrganizationUser, - create_organization, + OrganizationInvitation, ) +from kobo.apps.kobo_auth.shortcuts import User +from kobo.apps.project_ownership.models import InviteStatusChoices from kpi.utils.object_permission import get_database_user -from .constants import ORG_EXTERNAL_ROLE +from .constants import ( + ORG_EXTERNAL_ROLE, + INVITATION_OWNER_ERROR, + INVITATION_MEMBER_ERROR, + USER_DOES_NOT_EXIST_ERROR +) +from .tasks import transfer_member_data_ownership_to_org class OrganizationUserSerializer(serializers.ModelSerializer): @@ -163,3 +175,235 @@ def get_request_user_role(self, organization): return organization.get_user_role(user) return ORG_EXTERNAL_ROLE + + +class OrgMembershipInviteSerializer(serializers.ModelSerializer): + created = serializers.DateTimeField( + format='%Y-%m-%dT%H:%M:%SZ', read_only=True + ) + modified = serializers.DateTimeField( + format='%Y-%m-%dT%H:%M:%SZ', read_only=True + ) + invitees = serializers.ListField( + child=serializers.CharField(), + write_only=True, + required=True + ) + invited_by = serializers.SerializerMethodField() + url = serializers.SerializerMethodField() + + class Meta: + model = OrganizationInvitation + fields = [ + 'url', + 'invited_by', + 'invitees', + 'status', + 'created', + 'modified' + ] + + def create(self, validated_data): + """ + Create multiple invitations for the provided invitees. + + The `validated_data` is pre-processed by the `validate_invitees()` + method, which separates invitees into two groups: + - `users`: Registered and active users retrieved by email or username. + - `emails`: External email addresses for non-registered invitees. + + Args: + validated_data (dict): Data validated and pre-processed by the + serializer. + + Returns: + list: A list of created `OrganizationInvitation` instances. + """ + invited_by = self.context['request'].user + invitees = validated_data.get('invitees', {}) + valid_users = invitees.get('users', []) + external_emails = invitees.get('emails', []) + + invitations = [] + + # Create invitations for existing users + for user in valid_users: + invitation = OrganizationInvitation.objects.create( + invited_by=invited_by, + invitee=user, + organization=invited_by.organization, + ) + invitations.append(invitation) + invitation.send_invite_email() + + # Create invitations for external emails + for email in external_emails: + invitation = OrganizationInvitation.objects.create( + invited_by=invited_by, + invitee_identifier=email, + organization=invited_by.organization, + ) + invitations.append(invitation) + invitation.send_invite_email() + + return invitations + + def get_invited_by(self, invite): + return reverse( + 'user-kpi-detail', + args=[invite.invited_by.username], + request=self.context['request'] + ) + + def get_url(self, obj): + """ + Return the detail URL for the invitation + """ + return reverse( + 'organization-invites-detail', + kwargs={ + 'organization_id': obj.invited_by.organization.id, + 'guid': obj.guid + }, + request=self.context.get('request') + ) + + def to_representation(self, instance): + """ + Handle representation of invitation objects. Include `invitee` field + in the response + """ + representation = super().to_representation(instance) + if instance.invitee: + representation['invitee'] = instance.invitee.username + elif instance.invitee_identifier: + representation['invitee'] = instance.invitee_identifier + else: + representation['invitee'] = None + return representation + + def update(self, instance, validated_data): + status = validated_data.get('status') + if status == 'accepted': + self._handle_invitee_assignment(instance) + self.validate_invitation_acceptance(instance) + self._update_invitee_organization(instance) + + # Transfer ownership of user's assets to the organization + transfer_member_data_ownership_to_org.delay(instance.invitee.id) + self._handle_status_update(instance, status) + return instance + + def validate_invitation_acceptance(self, instance): + """ + Validate the acceptance of an invitation + """ + request_user = self.context['request'].user + + # Check if the invitation has already been accepted + if instance.status == InviteStatusChoices.ACCEPTED: + raise PermissionDenied( + { + 'detail': 'Invitation has already been accepted.' + } + ) + + # Validate email or username + is_email_match = request_user.email == instance.invitee_identifier + is_username_match = ( + instance.invitee and + request_user.username == instance.invitee.username + ) + if not (is_email_match or is_username_match): + raise NotFound({'detail': 'Invitation not found.'}) + + # Check if the invitee is already a member of the organization + if instance.invitee.organization.is_mmo: + if instance.invitee.organization.is_owner(request_user): + raise PermissionDenied( + { + 'detail': INVITATION_OWNER_ERROR.format( + organization_name=instance.invitee.organization.name + ) + } + ) + else: + raise PermissionDenied( + { + 'detail': INVITATION_MEMBER_ERROR.format( + organization_name=instance.invitee.organization.name + ) + } + ) + + def validate_invitees(self, value): + """ + Check if usernames exist in the database, and emails are valid. + """ + valid_users = [] + external_emails = [] + for idx, invitee in enumerate(value): + try: + validate_email(invitee) + users = User.objects.filter(email=invitee) + if users: + user = users.filter(is_active=True).first() + if user: + valid_users.append(user) + else: + raise serializers.ValidationError( + USER_DOES_NOT_EXIST_ERROR.format(invitee=invitee) + ) + else: + external_emails.append(invitee) + except ValidationError: + user = User.objects.filter( + username=invitee, is_active=True + ).first() + if user: + valid_users.append(user) + else: + raise serializers.ValidationError( + USER_DOES_NOT_EXIST_ERROR.format(invitee=invitee) + ) + return {'users': valid_users, 'emails': external_emails} + + def _handle_invitee_assignment(self, instance): + """ + Assigns the invitee to the invitation after the external user registers + and accepts the invitation + """ + invitee_identifier = instance.invitee_identifier + if invitee_identifier and not instance.invitee: + try: + instance.invitee = User.objects.get(email=invitee_identifier) + instance.save(update_fields=['invitee']) + except User.DoesNotExist: + raise serializers.ValidationError( + 'No user found with the specified email.' + ) + + def _handle_status_update(self, instance, status): + instance.status = getattr(InviteStatusChoices, status.upper()) + instance.save(update_fields=['status']) + self._send_status_email(instance, status) + + def _send_status_email(self, instance, status): + status_map = { + 'accepted': instance.send_acceptance_email, + 'declined': instance.send_refusal_email, + 'resent': instance.send_invite_email + } + + email_func = status_map.get(status) + if email_func: + email_func() + + def _update_invitee_organization(self, instance): + """ + Update the organization of the invitee after accepting the invitation + """ + org_user = OrganizationUser.objects.get(user=instance.invitee) + Organization.objects.filter(organization_users=org_user).delete() + org_user.organization = instance.invited_by.organization + org_user.save() diff --git a/kobo/apps/organizations/tasks.py b/kobo/apps/organizations/tasks.py index c9717f023f..858053abac 100644 --- a/kobo/apps/organizations/tasks.py +++ b/kobo/apps/organizations/tasks.py @@ -2,6 +2,7 @@ from more_itertools import chunked from kobo.apps.kobo_auth.shortcuts import User +from kobo.apps.organizations.models import Organization from kobo.apps.project_ownership.models import Transfer from kobo.apps.project_ownership.utils import create_invite from kobo.celery import celery_app @@ -14,7 +15,10 @@ ) def transfer_member_data_ownership_to_org(user_id: int): sender = User.objects.get(pk=user_id) - recipient = sender.organization.owner_user_object + sender_organization = Organization.objects.filter( + organization_users__user=sender + ).first() + recipient = sender_organization.owner_user_object user_assets = ( sender.assets.only('pk', 'uid') .exclude( diff --git a/kobo/apps/organizations/templates/emails/accepted_invite.html b/kobo/apps/organizations/templates/emails/accepted_invite.html new file mode 100644 index 0000000000..736d500944 --- /dev/null +++ b/kobo/apps/organizations/templates/emails/accepted_invite.html @@ -0,0 +1,12 @@ +{% load i18n %} +

{% trans "Dear" %} {{ sender_username }},

+ +

{% blocktrans %}{{ recipient_username }} ({{ recipient_email }}) has accepted your request to join {{ organization_name }}’s organization.{% endblocktrans %}

+ +

{% trans "All projects, submissions, data storage, transcription and translation usage for their projects will be transferred to you." %}

+ +

{% trans "Note: You will continue to have permissions to manage these projects until the user permissions are changed." %}

+ +

+ - KoboToolbox +

diff --git a/kobo/apps/organizations/templates/emails/accepted_invite.txt b/kobo/apps/organizations/templates/emails/accepted_invite.txt new file mode 100644 index 0000000000..05d65f266d --- /dev/null +++ b/kobo/apps/organizations/templates/emails/accepted_invite.txt @@ -0,0 +1,10 @@ +{% load i18n %} +{% trans "Dear" %} {{ sender_username }}, + +{% blocktrans %}{{ recipient_username }} ({{ recipient_email }}) has accepted your request to join {{ organization_name }}’s organization.{% endblocktrans %} + +{% trans "All projects, submissions, data storage, transcription and translation usage for their projects will be transferred to you." %} + +{% trans "Note: You will continue to have permissions to manage these projects until the user permissions are changed." %} + +- KoboToolbox diff --git a/kobo/apps/organizations/templates/emails/declined_invite.html b/kobo/apps/organizations/templates/emails/declined_invite.html new file mode 100644 index 0000000000..8470fecce1 --- /dev/null +++ b/kobo/apps/organizations/templates/emails/declined_invite.html @@ -0,0 +1,7 @@ +{% load i18n %} +

{% trans "Dear" %} {{ sender_username }},

+ +

{% blocktrans %}{{ recipient_username }} ({{ recipient_email }}) has declined your request to join {{ organization_name }}’s organization.{% endblocktrans %}

+

+ - KoboToolbox +

diff --git a/kobo/apps/organizations/templates/emails/declined_invite.txt b/kobo/apps/organizations/templates/emails/declined_invite.txt new file mode 100644 index 0000000000..6f1e098844 --- /dev/null +++ b/kobo/apps/organizations/templates/emails/declined_invite.txt @@ -0,0 +1,6 @@ +{% load i18n %} +{% trans "Dear" %} {{ sender_username }}, + +{% blocktrans %}{{ recipient_username }} ({{ recipient_email }}) has declined your request to join {{ organization_name }}’s organization.{% endblocktrans %} + +- KoboToolbox diff --git a/kobo/apps/organizations/templates/emails/new_invite.html b/kobo/apps/organizations/templates/emails/new_invite.html new file mode 100644 index 0000000000..0cf9b93bf3 --- /dev/null +++ b/kobo/apps/organizations/templates/emails/new_invite.html @@ -0,0 +1,19 @@ +{% load i18n %} +

{% trans "Hello," %}

+ +

{% blocktrans %}{{ sender_username }} ({{ sender_email }}, username: {{ sender_username }}) has invited you (username: {{ recipient_username }}) to join {{ organization_name }}’s organization.{% endblocktrans %}

+ +

{% trans "What joining the organization means for you:" %}

+ + +

{% trans "If you want to transfer any projects to another account or remove a project, please do so before accepting the invitation. This action cannot be undone." %}

+ +

{% blocktrans %}To respond to this invitation, please use the following link: {{ base_url }}/#/projects/home?organization-invite={{ invite_uid }}{% endblocktrans %}

+ +

+ - KoboToolbox +

diff --git a/kobo/apps/organizations/templates/emails/new_invite.txt b/kobo/apps/organizations/templates/emails/new_invite.txt new file mode 100644 index 0000000000..8554272554 --- /dev/null +++ b/kobo/apps/organizations/templates/emails/new_invite.txt @@ -0,0 +1,15 @@ +{% load i18n %} +{% trans "Hello," %} + +{% blocktrans %}{{ sender_username }} ({{ sender_email }}, username: {{ sender_username }}) has invited you (username: {{ recipient_username }}) to join {{ organization_name }}’s organization.{% endblocktrans %} + +{% trans "What joining the organization means for you:" %} +* {% trans "You will benefit from higher usage limits and additional features, as well as priority user support." %} +* {% trans "Any projects owned by your account will be transferred to the organization and all admins in that organization will have access to your projects and data." %} +* {% trans "You will continue to have full management permission of all projects previously owned by you." %} + +{% trans "If you want to transfer any projects to another account or remove a project, please do so before accepting the invitation. This action cannot be undone." %} + +{% blocktrans %}To respond to this invitation, please use the following link: {{ base_url }}/#/projects/home?organization-invite={{ invite_uid }}{% endblocktrans %} + +- KoboToolbox diff --git a/kobo/apps/organizations/tests/test_organization_invitations.py b/kobo/apps/organizations/tests/test_organization_invitations.py new file mode 100644 index 0000000000..c69ec36137 --- /dev/null +++ b/kobo/apps/organizations/tests/test_organization_invitations.py @@ -0,0 +1,121 @@ +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.models import OrganizationInvitation +from kobo.apps.organizations.tests.test_organizations_api import ( + BaseOrganizationAssetApiTestCase +) +from kpi.models import Asset +from kpi.urls.router_api_v2 import URL_NAMESPACE + + +@ddt +class OrganizationInvitationTestCase(BaseOrganizationAssetApiTestCase): + fixtures = ['test_data'] + URL_NAMESPACE = URL_NAMESPACE + + def setUp(self): + super().setUp() + self.organization = self.someuser.organization + self.owner_user = self.someuser + self.external_user = self.bob + + self.list_url = reverse( + self._get_endpoint('organization-invites-list'), + kwargs={'organization_id': self.organization.id}, + ) + self.detail_url = lambda guid: reverse( + self._get_endpoint('organization-invites-detail'), + kwargs={ + 'organization_id': self.organization.id, + 'guid': guid + }, + ) + self.invitation_data = { + "invitees": ["bob", "unregistereduser@example.com"] + } + + def _create_invite(self, user): + """ + Helper method to create invitations + """ + self.client.force_login(user) + return self.client.post(self.list_url, data=self.invitation_data) + + def _update_invite(self, user, guid, status): + """ + Helper method to update invitation status + """ + self.client.force_login(user) + return self.client.patch(self.detail_url(guid), data={'status': status}) + + def test_create_invite(self): + """ + Test that the organization owner can create invitations + """ + response = self._create_invite(self.owner_user) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual( + len(response.data), len(self.invitation_data['invitees']) + ) + for invitation in response.data: + self.assertIn( + invitation['invitee'], self.invitation_data['invitees'] + ) + + def test_list_invites(self): + """ + Test listing of invitations by the organization owner + """ + self._create_invite(self.owner_user) + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + for invite in response.data['results']: + self.assertIn('url', invite) + self.assertIn('invitee', invite) + self.assertIn('invited_by', invite) + + @data( + ('accepted', status.HTTP_200_OK), + ('declined', status.HTTP_200_OK), + ) + @unpack + def test_update_invite_for_registered_user(self, status, expected_status): + self._create_invite(self.owner_user) + self.client.force_login(self.external_user) + create_asset_response = self._create_asset_by_bob() + guid = OrganizationInvitation.objects.get( + invitee=self.external_user + ).guid + response = self._update_invite(self.external_user, guid, status) + self.assertEqual(response.status_code, expected_status) + self.assertEqual(response.data['status'], status) + + bob_asset = Asset.objects.get(uid=create_asset_response.data['uid']) + if status == 'accepted': + self.assertEqual(bob_asset.owner, self.owner_user) + + @data( + ('accepted', status.HTTP_200_OK), + ('declined', status.HTTP_200_OK), + ) + @unpack + def test_update_invite_for_unregistered_user(self, status, expected_status): + """ + Test that an unregistered user can update their invitation status + """ + self._create_invite(self.owner_user) + self.new_user = User.objects.create_user( + username='new_user', + email='unregistereduser@example.com', + password='new_user' + ) + self.client.force_login(self.new_user) + guid = OrganizationInvitation.objects.get( + invitee_identifier=self.new_user.email + ).guid + response = self._update_invite(self.new_user, guid, status) + self.assertEqual(response.status_code, expected_status) + self.assertEqual(response.data['status'], status) diff --git a/kobo/apps/organizations/views.py b/kobo/apps/organizations/views.py index 55a55c52c9..fb70da567d 100644 --- a/kobo/apps/organizations/views.py +++ b/kobo/apps/organizations/views.py @@ -1,7 +1,7 @@ from django.db.models import Case, CharField, OuterRef, QuerySet, Value, When from django.db.models.expressions import Exists from django.utils.http import http_date -from rest_framework import viewsets +from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.request import Request from rest_framework.response import Response @@ -17,13 +17,22 @@ from kpi.utils.object_permission import get_database_user from kpi.views.v2.asset import AssetViewSet from ..accounts.mfa.models import MfaMethod -from .models import Organization, OrganizationOwner, OrganizationUser +from .models import ( + Organization, + OrganizationOwner, + OrganizationUser, + OrganizationInvitation +) from .permissions import ( HasOrgRolePermission, IsOrgAdminPermission, OrganizationNestedHasOrgRolePermission, ) -from .serializers import OrganizationSerializer, OrganizationUserSerializer +from .serializers import ( + OrganizationSerializer, + OrganizationUserSerializer, + OrgMembershipInviteSerializer +) class OrganizationAssetViewSet(AssetViewSet): @@ -420,3 +429,118 @@ def get_queryset(self): has_mfa_enabled=Exists(mfa_subquery) ) return queryset + + +class OrgMembershipInviteViewSet(viewsets.ModelViewSet): + """ + ### List Organization Invitation + +
+    GET /api/v2/organizations/{organization_id}/invites/
+    
+ + > Example + > + > curl -X GET https://[kpi]/api/v2/organizations/org_12345/invites/ + + > Response 200 + + > { + > "count": 2, + > "next": null, + > "previous": null, + > "results": [ + > { + > "url": "http://kf.kobo.local/api/v2/organizations/ + org3ua6H3F94CQpQEYs4RRz4/invites/ + f361ebf6-d1c1-4ced-8343-04b11863d784/", + > "invited_by": "http://kf.kobo.local/api/v2/users/demo7/", + > "status": "pending", + > "created": "2024-12-11T16:00:00Z", + > "modified": "2024-12-11T16:00:00Z", + > "invitee": "raj_patel" + > }, + > { + > "url": "http://kf.kobo.local/api/v2/organizations/ + orgLRM8xmvWji4itYWWhLVgC/invites/ + 1a8b93bf-eec5-4e56-bd4a-5f7657e6a2fd/", + > "invited_by": "http://kf.kobo.local/api/v2/users/raj_patel/", + > "status": "pending", + > "created": "2024-12-11T18:19:56Z", + > "modified": "2024-12-11T18:19:56Z", + > "invitee": "demo7" + > }, + > ] + > } + + ### Create Organization Invitation + +
+    POST /api/v2/organizations/{organization_id}/invites/
+    
+ + > Example + > + > curl -X POST https://[kpi]/api/v2/organizations/org_12345/invites/ + + > Payload + + > { + > "invitees": ["demo14", "demo13@demo13.com", "demo20@demo20.com"] + > } + + > Response 200 + + > [ + > { + > "url": "http://kf.kobo.local/api/v2/organizations/ + orgLRM8xmvWji4itYWWhLVgC/invites/ + f3ba00b2-372b-4283-9d57-adbe7d5b1bf1/", + > "invited_by": "http://kf.kobo.local/api/v2/users/raj_patel/", + > "status": "pending", + > "created": "2024-12-20T13:35:13Z", + > "modified": "2024-12-20T13:35:13Z", + > "invitee": "demo14" + > }, + > { + > "url": "http://kf.kobo.local/api/v2/organizations/ + orgLRM8xmvWji4itYWWhLVgC/invites/ + 5e79e0b4-6de4-4901-bbe5-59807fcdd99a/", + > "invited_by": "http://kf.kobo.local/api/v2/users/raj_patel/", + > "status": "pending", + > "created": "2024-12-20T13:35:13Z", + > "modified": "2024-12-20T13:35:13Z", + > "invitee": "demo13" + > }, + > { + > "url": "http://kf.kobo.local/api/v2/organizations/ + orgLRM8xmvWji4itYWWhLVgC/invites/ + 3efb7217-171f-47a5-9a42-b23055e499d4/", + > "invited_by": "http://kf.kobo.local/api/v2/users/raj_patel/", + > "status": "pending", + > "created": "2024-12-20T13:35:13Z", + > "modified": "2024-12-20T13:35:13Z", + > "invitee": "demo20@demo20.com" + > } + > ] + """ + serializer_class = OrgMembershipInviteSerializer + permission_classes = [] + http_method_names = ['get', 'post', 'patch', 'delete'] + lookup_field = 'guid' + + def get_queryset(self): + return OrganizationInvitation.objects.select_related( + 'invitee', 'invited_by', 'organization' + ) + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + invitations = serializer.save() + + # Return the serialized data for all created invitations + serializer = OrgMembershipInviteSerializer( + invitations, many=True, context={'request': request} + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) diff --git a/kpi/urls/router_api_v2.py b/kpi/urls/router_api_v2.py index b2c3f90fac..7ca89451ee 100644 --- a/kpi/urls/router_api_v2.py +++ b/kpi/urls/router_api_v2.py @@ -8,7 +8,11 @@ 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 OrganizationMemberViewSet, OrganizationViewSet +from kobo.apps.organizations.views import ( + OrganizationMemberViewSet, + OrganizationViewSet, + OrgMembershipInviteViewSet +) 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 @@ -155,6 +159,11 @@ def get_urls(self, *args, **kwargs): OrganizationMemberViewSet, basename='organization-members', ) +router_api_v2.register( + r'organizations/(?P[^/.]+)/invites', + OrgMembershipInviteViewSet, + basename='organization-invites', +) router_api_v2.register(r'permissions', PermissionViewSet) router_api_v2.register(r'project-views', ProjectViewViewSet)