diff --git a/kobo/apps/organizations/models.py b/kobo/apps/organizations/models.py index 77be7c52ec..e1891872ed 100644 --- a/kobo/apps/organizations/models.py +++ b/kobo/apps/organizations/models.py @@ -1,6 +1,7 @@ from functools import partial from typing import Literal +from django.apps import apps from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.db import models @@ -321,10 +322,15 @@ def send_acceptance_email(self): Mailer.send(email_message) def send_invite_email(self): - """ - Sends an email to invite a user to join a team as an admin. - """ is_registered_user = bool(self.invitee) + to_email = ( + self.invitee.email + if is_registered_user + else self.invitee_identifier + ) + # To avoid circular import + User = apps.get_model('kobo_auth', 'User') + has_multiple_accounts = User.objects.filter(email=to_email).count() > 1 template_variables = { 'sender_name': self.invited_by.extra_details.data['name'], 'sender_username': self.invited_by.username, @@ -334,10 +340,12 @@ def send_invite_email(self): if is_registered_user else self.invitee_identifier ), + 'recipient_role': self.invitee_role, 'organization_name': self.invited_by.organization.name, 'base_url': settings.KOBOFORM_URL, 'invite_uid': self.guid, 'is_registered_user': is_registered_user, + 'has_multiple_accounts': has_multiple_accounts, } if is_registered_user: @@ -348,12 +356,8 @@ def send_invite_email(self): text_template = 'emails/unregistered_user_invite.txt' email_message = EmailMessage( - to=( - self.invitee.email - if is_registered_user - else self.invitee_identifier - ), - subject='Invitation to Join the Organization', + to=to_email, + subject='Invitation to Join the KoboToolbox Organization', plain_text_content_or_template=text_template, template_variables=template_variables, html_content_or_template=html_template, diff --git a/kobo/apps/organizations/serializers.py b/kobo/apps/organizations/serializers.py index 2724518cc9..3a083015d5 100644 --- a/kobo/apps/organizations/serializers.py +++ b/kobo/apps/organizations/serializers.py @@ -211,6 +211,47 @@ class Meta: 'modified' ] + def _handle_invitee_assignment(self, instance): + """ + Assigns the invitee to the invite after the external user registers + and accepts the invite + """ + 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 NotFound({'detail': t(INVITE_NOT_FOUND_ERROR)}) + + def _handle_status_update(self, instance, status): + instance.status = getattr( + OrganizationInviteStatusChoices, 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.is_admin = instance.invitee_role == 'admin' + org_user.save() + def create(self, validated_data): """ Create multiple invitations for the provided invitees. @@ -378,46 +419,3 @@ def validate_invitees(self, value): 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 invite after the external user registers - and accepts the invite - """ - 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( - OrganizationInviteStatusChoices, 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.is_admin = instance.invitee_role == 'admin' - org_user.save() diff --git a/kobo/apps/organizations/tasks.py b/kobo/apps/organizations/tasks.py index 948d5fa061..c420b6cd33 100644 --- a/kobo/apps/organizations/tasks.py +++ b/kobo/apps/organizations/tasks.py @@ -25,6 +25,8 @@ ) def transfer_member_data_ownership_to_org(user_id: int): sender = User.objects.get(pk=user_id) + # Get sender's organization without using the cached organization property, + # as it may be outdated. sender_organization = Organization.objects.filter( organization_users__user=sender ).first() diff --git a/kobo/apps/organizations/templates/emails/accepted_invite.html b/kobo/apps/organizations/templates/emails/accepted_invite.html index 736d500944..d74fd8bc5f 100644 --- a/kobo/apps/organizations/templates/emails/accepted_invite.html +++ b/kobo/apps/organizations/templates/emails/accepted_invite.html @@ -1,7 +1,7 @@ {% load i18n %}
{% trans "Dear" %} {{ sender_username }},
-{% blocktrans %}{{ recipient_username }} ({{ recipient_email }}) has accepted your request to join {{ organization_name }}’s organization.{% endblocktrans %}
+{% blocktrans %}{{ recipient_username }} ({{ recipient_email }}) has accepted your request to join {{ organization_name }} organization.{% endblocktrans %}
{% trans "All projects, submissions, data storage, transcription and translation usage for their projects will be transferred to you." %}
diff --git a/kobo/apps/organizations/templates/emails/accepted_invite.txt b/kobo/apps/organizations/templates/emails/accepted_invite.txt index 05d65f266d..87213ba0be 100644 --- a/kobo/apps/organizations/templates/emails/accepted_invite.txt +++ b/kobo/apps/organizations/templates/emails/accepted_invite.txt @@ -1,7 +1,7 @@ {% load i18n %} {% trans "Dear" %} {{ sender_username }}, -{% blocktrans %}{{ recipient_username }} ({{ recipient_email }}) has accepted your request to join {{ organization_name }}’s organization.{% endblocktrans %} +{% blocktrans %}{{ recipient_username }} ({{ recipient_email }}) has accepted your request to join {{ organization_name }} organization.{% endblocktrans %} {% trans "All projects, submissions, data storage, transcription and translation usage for their projects will be transferred to you." %} diff --git a/kobo/apps/organizations/templates/emails/declined_invite.html b/kobo/apps/organizations/templates/emails/declined_invite.html index b6388242a1..0c6b041565 100644 --- a/kobo/apps/organizations/templates/emails/declined_invite.html +++ b/kobo/apps/organizations/templates/emails/declined_invite.html @@ -1,7 +1,7 @@ {% load i18n %}{% trans "Dear" %} {{ sender_username }},
-{% blocktrans %}{{ recipient }} has declined your request to join {{ organization_name }}’s organization.{% endblocktrans %}
+{% blocktrans %}{{ recipient }} has declined your request to join {{ organization_name }} organization.{% endblocktrans %}
- KoboToolbox
diff --git a/kobo/apps/organizations/templates/emails/declined_invite.txt b/kobo/apps/organizations/templates/emails/declined_invite.txt index b3741a4053..ef07f0ed63 100644 --- a/kobo/apps/organizations/templates/emails/declined_invite.txt +++ b/kobo/apps/organizations/templates/emails/declined_invite.txt @@ -1,6 +1,6 @@ {% load i18n %} {% trans "Dear" %} {{ sender_username }}, -{% blocktrans %}{{ recipient }} has declined your request to join {{ organization_name }}’s organization.{% endblocktrans %} +{% blocktrans %}{{ recipient }} has declined your request to join {{ organization_name }} organization.{% endblocktrans %} - KoboToolbox diff --git a/kobo/apps/organizations/templates/emails/expired_invite.html b/kobo/apps/organizations/templates/emails/expired_invite.html index eb38a608eb..3262512b68 100644 --- a/kobo/apps/organizations/templates/emails/expired_invite.html +++ b/kobo/apps/organizations/templates/emails/expired_invite.html @@ -2,7 +2,7 @@ {% load strings %}{% trans "Dear" %} {{ username }},
-{% blocktrans %}The request you have sent to {{ recipient }} to join the {{ organization }}’s organization has expired.{% endblocktrans %}
+{% blocktrans %}The request you have sent to {{ recipient }} to join the {{ organization }} organization has expired.{% endblocktrans %}
- KoboToolbox diff --git a/kobo/apps/organizations/templates/emails/expired_invite.txt b/kobo/apps/organizations/templates/emails/expired_invite.txt index 980c0dd6c9..9d956bd04e 100644 --- a/kobo/apps/organizations/templates/emails/expired_invite.txt +++ b/kobo/apps/organizations/templates/emails/expired_invite.txt @@ -2,6 +2,6 @@ {% load strings %} {% trans "Dear" %} {{ username }}, -{% blocktrans %}The request you have sent to {{ recipient }} to join the {{ organization }}’s organization has expired.{% endblocktrans %} +{% blocktrans %}The request you have sent to {{ recipient }} to join the {{ organization }} organization has expired.{% endblocktrans %} - KoboToolbox diff --git a/kobo/apps/organizations/templates/emails/registered_user_invite.html b/kobo/apps/organizations/templates/emails/registered_user_invite.html index b1337565aa..5dfb2d892d 100644 --- a/kobo/apps/organizations/templates/emails/registered_user_invite.html +++ b/kobo/apps/organizations/templates/emails/registered_user_invite.html @@ -1,15 +1,20 @@ {% load i18n %}
{% trans "Hello," %}
-{% blocktrans %}{{ sender_name }} ({{ sender_email }}, username: {{ sender_username }}) has invited you (username: {{ recipient_username }}) to join {{ organization_name }} organization.{% endblocktrans %}
+{% blocktrans %}{{ sender_name }} ({{ sender_email }}, username: {{ sender_username }}) has invited you (username: {{ recipient_username }}) to join {{ organization_name }} organization as {{recipient_role}}.{% endblocktrans %}
+{% if has_multiple_accounts %} +If you already have an account (or have several accounts), please sign in with the correct account.
+{% endif %} -{% trans "What joining the team means for you:" %}
+{% 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/registered_user_invite.txt b/kobo/apps/organizations/templates/emails/registered_user_invite.txt index 6371259c47..8348d664d2 100644 --- a/kobo/apps/organizations/templates/emails/registered_user_invite.txt +++ b/kobo/apps/organizations/templates/emails/registered_user_invite.txt @@ -1,13 +1,18 @@ {% load i18n %} {% trans "Hello," %} -{% blocktrans %}{{ sender_name }} ({{ sender_email }}, username: {{ sender_username }}) has invited you (username: {{ recipient_username }}) to join {{ organization_name }} organization.{% endblocktrans %} +{% blocktrans %}{{ sender_name }} ({{ sender_email }}, username: {{ sender_username }}) has invited you (username: {{ recipient_username }}) to join {{ organization_name }} organization as {{recipient_role}}.{% endblocktrans %} +{% if has_multiple_accounts %} +If you already have an account (or have several accounts), please sign in with the correct account. +{% endif %} -{% trans "What joining the team means for you:" %} +{% 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." %} * {% blocktrans %}Any projects owned by your account will be transferred to {{ organization_name }} and all admins in that organization will have access to your projects and data. {% endblocktrans %} * {% trans "You will continue to have full management permissions for 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/templates/emails/unregistered_user_invite.html b/kobo/apps/organizations/templates/emails/unregistered_user_invite.html index 5a3abae6e2..c1ccf926de 100644 --- a/kobo/apps/organizations/templates/emails/unregistered_user_invite.html +++ b/kobo/apps/organizations/templates/emails/unregistered_user_invite.html @@ -1,16 +1,16 @@ {% load i18n %}{% trans "Hello," %}
-{% blocktrans %}We’d like to give you a warm welcome to KoboToolbox. You’re invited to join {{ organization_name }} organization as a member with {{ recipient_username }}.{% endblocktrans %}
+{% blocktrans %}We’d like to give you a warm welcome to KoboToolbox. You’re invited to join {{ organization_name }} organization as {{recipient_role}} with {{ recipient_username }}.{% endblocktrans %}
-{% trans "What joining the team means for you:" %}
+{% trans "What joining the organization means for you:" %}
{% trans "It takes less than 2 minutes to create your account to join the organization. Please create your account here: " %}{{ base_url }}.
+{% blocktrans %}It takes less than 2 minutes to create your account to join the organization. Please create your account here: {{ base_url }}/accounts/signup/{% endblocktrans %}
{% trans "Once you have finished creating your account, respond to this invitation using the following link:" %}
{% blocktrans %}{{ base_url }}/#/projects/home?organization-invite={{ invite_uid }}{% endblocktrans %}
diff --git a/kobo/apps/organizations/templates/emails/unregistered_user_invite.txt b/kobo/apps/organizations/templates/emails/unregistered_user_invite.txt index 8923a2691f..fb9247563f 100644 --- a/kobo/apps/organizations/templates/emails/unregistered_user_invite.txt +++ b/kobo/apps/organizations/templates/emails/unregistered_user_invite.txt @@ -1,14 +1,14 @@ {% load i18n %} {% trans "Hello," %} -{% blocktrans %}We’d like to give you a warm welcome to KoboToolbox. You’re invited to join {{ organization_name }} organization as a member with {{ recipient_username }}. {% endblocktrans %} +{% blocktrans %}We’d like to give you a warm welcome to KoboToolbox. You’re invited to join {{ organization_name }} organization as {{recipient_role}} with {{ recipient_username }}. {% endblocktrans %} -{% trans "What joining the team means for you:" %} +{% 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." %} * {% blocktrans %}Any projects owned by your account will be transferred to {{ organization_name }} and all admins in that organization will have access to your projects and data. {% endblocktrans %} * {% trans "You will continue to have full management permissions for all projects previously owned by you." %} -{% trans "It takes less than 2 minutes to create your account to join the organization. Please create your account here: " %}{{ base_url }}. +{% blocktrans %}It takes less than 2 minutes to create your account to join the organization. Please create your account here: {{ base_url }}/accounts/signup/{% endblocktrans %} {% trans "Once you have finished creating your account, respond to this invitation using the following link:" %} {% blocktrans %}{{ base_url }}/#/projects/home?organization-invite={{ invite_uid }}{% endblocktrans %} diff --git a/kobo/apps/organizations/tests/test_organization_invitations.py b/kobo/apps/organizations/tests/test_organization_invitations.py index 69ec8741eb..a4bf695578 100644 --- a/kobo/apps/organizations/tests/test_organization_invitations.py +++ b/kobo/apps/organizations/tests/test_organization_invitations.py @@ -6,7 +6,13 @@ from rest_framework import status from kobo.apps.kobo_auth.shortcuts import User -from kobo.apps.organizations.models import OrganizationInvitation +from kobo.apps.organizations.constants import ( + INVITE_OWNER_ERROR, + INVITE_MEMBER_ERROR, + INVITE_ALREADY_ACCEPTED_ERROR, + INVITE_NOT_FOUND_ERROR +) +from kobo.apps.organizations.models import OrganizationInvitation, Organization from kobo.apps.organizations.tasks import mark_organization_invite_as_expired from kobo.apps.organizations.tests.test_organizations_api import ( BaseOrganizationAssetApiTestCase @@ -25,6 +31,7 @@ def setUp(self): self.organization = self.someuser.organization self.owner_user = self.someuser self.admin_user = self.anotheruser + self.member_user = self.alice self.external_user = self.bob self.list_url = reverse( @@ -39,7 +46,7 @@ def setUp(self): }, ) self.invitation_data = { - "invitees": ["bob", "unregistereduser@example.com"] + 'invitees': ['bob', 'unregistereduser@example.com'] } def _create_invite(self, user): @@ -178,11 +185,40 @@ def test_unregistered_user_can_accept_invitation(self): self.assertEqual(response.data['status'], 'accepted') self.assertEqual(mail.outbox[2].to[0], invitation.invited_by.email) + @data( + ('admin', status.HTTP_201_CREATED), + ('member', status.HTTP_201_CREATED) + ) + @unpack + def test_user_invitation_by_role(self, role, expected_status): + """ + Test that a user can be invited as an admin or member + """ + self.invitation_data['role'] = role + response = self._create_invite(self.owner_user) + self.assertEqual(response.status_code, expected_status) + self.assertEqual( + response.data[0]['invitee_role'], role + ) + self.client.force_login(self.external_user) + invitation = OrganizationInvitation.objects.get( + invitee=self.external_user + ) + response = self._update_invite( + self.external_user, invitation.guid, 'accepted' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], 'accepted') + self.assertEqual(response.data['invitee_role'], role) + self.assertEqual( + self.organization.get_user_role(self.external_user), role + ) + @data( ('owner', status.HTTP_204_NO_CONTENT), ('admin', status.HTTP_204_NO_CONTENT), - # ('member', status.HTTP_403_FORBIDDEN), - # ('external', status.HTTP_404_NOT_FOUND), + ('member', status.HTTP_403_FORBIDDEN), + ('external', status.HTTP_403_FORBIDDEN), ) @unpack def test_owner_or_admin_can_delete_invitation(self, user_role, expected_status): @@ -216,3 +252,137 @@ def test_sender_receives_expired_notification(self): self.assertEqual( mail.outbox[0].subject, 'Organization invite has expired' ) + + +class OrganizationInviteValidationTestCase(OrganizationInviteTestCase): + fixtures = ['test_data'] + URL_NAMESPACE = URL_NAMESPACE + + def test_invitee_cannot_accept_invitation_twice(self): + """ + Test that a user cannot accept an invitation that has already + been accepted + """ + self._create_invite(self.owner_user) + self.client.force_login(self.external_user) + invitation = OrganizationInvitation.objects.get( + invitee=self.external_user + ) + response = self._update_invite( + self.external_user, invitation.guid, 'accepted' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], 'accepted') + + # Attempt to accept the invitation again + response = self._update_invite( + self.external_user, invitation.guid, 'accepted' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual( + response.data['detail'], INVITE_ALREADY_ACCEPTED_ERROR + ) + + def test_invitee_cannot_accept_if_already_member_of_organization(self): + """ + Test that a user cannot accept an invitation if they are already a part + of another organization + """ + self.another_owner_user = User.objects.create_user( + username='another_owner_user', + email='another_owner_user@example.com', + password='password' + ) + self.another_admin_user = User.objects.create_user( + username='another_admin_user', + email='another_admin_user@example.com', + password='password' + ) + self.another_organization = Organization.objects.create( + id='org1234', name='Another Organization', mmo_override=True + ) + self.another_organization.add_user(self.another_owner_user) + self.another_organization.add_user( + self.another_admin_user, is_admin=True + ) + self.invitation_data['invitees'] = [ + 'another_owner_user', 'another_admin_user' + ] + self._create_invite(self.owner_user) + + # Attempt to accept the invitation as the owner of another organization + self.client.force_login(self.another_owner_user) + invitation = OrganizationInvitation.objects.get( + invitee=self.another_owner_user + ) + response = self._update_invite( + self.another_owner_user, invitation.guid, 'accepted' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual( + response.data['detail'], + INVITE_OWNER_ERROR.format( + organization_name=self.another_organization.name + ) + ) + + # Attempt to accept the invitation as an admin of another organization + self.client.force_login(self.another_admin_user) + invitation = OrganizationInvitation.objects.get( + invitee=self.another_admin_user + ) + response = self._update_invite( + self.another_admin_user, invitation.guid, 'accepted' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual( + response.data['detail'], + INVITE_MEMBER_ERROR.format( + organization_name=self.another_organization.name + ) + ) + + def test_invitee_with_different_username_cannot_accept_invitation(self): + """ + Test that a user cannot accept an invitation with a different username + """ + self._create_invite(self.owner_user) + self.new_user = User.objects.create_user( + username='new_user', + email='new_user@example.com', + password='password' + ) + self.client.force_login(self.new_user) + invitation = OrganizationInvitation.objects.get( + invitee=self.external_user + ) + response = self._update_invite( + self.new_user, invitation.guid, 'accepted' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual( + response.data['detail'], INVITE_NOT_FOUND_ERROR + ) + + def test_invitee_with_different_email_cannot_accept_invitation(self): + """ + Test that a user cannot accept an invitation with a different email + """ + self._create_invite(self.owner_user) + # Create a new user with a different email + self.new_user = User.objects.create_user( + username='new_user', + email='new_user@example.com', + password='password' + ) + + # Attempt to accept the invitation + self.client.force_login(self.new_user) + invitation = OrganizationInvitation.objects.get( + invitee_identifier='unregistereduser@example.com' + ) + response = self._update_invite( + self.new_user, invitation.guid, 'accepted' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.data['detail'], INVITE_NOT_FOUND_ERROR) diff --git a/kobo/apps/organizations/views.py b/kobo/apps/organizations/views.py index 6de7144e5c..7a9d31b306 100644 --- a/kobo/apps/organizations/views.py +++ b/kobo/apps/organizations/views.py @@ -453,8 +453,7 @@ class OrgMembershipInviteViewSet(viewsets.ModelViewSet): > "results": [ > { > "url": "http://kf.kobo.local/api/v2/organizations/ - org3ua6H3F94CQpQEYs4RRz4/invites/ - f361ebf6-d1c1-4ced-8343-04b11863d784/", + org3ua6H3F94CQpQEYs4RRz4/invites/f361ebf6-d1c1-4ced-8343-04b11863d784/", > "invited_by": "http://kf.kobo.local/api/v2/users/demo7/", > "status": "pending", > "invitee_role": "member", @@ -464,8 +463,7 @@ class OrgMembershipInviteViewSet(viewsets.ModelViewSet): > }, > { > "url": "http://kf.kobo.local/api/v2/organizations/ - orgLRM8xmvWji4itYWWhLVgC/invites/ - 1a8b93bf-eec5-4e56-bd4a-5f7657e6a2fd/", + orgLRM8xmvWji4itYWWhLVgC/invites/1a8b93bf-eec5-4e56-bd4a-5f7657e6a2fd/", > "invited_by": "http://kf.kobo.local/api/v2/users/raj_patel/", > "status": "pending", > "invitee_role": "member", @@ -502,8 +500,7 @@ class OrgMembershipInviteViewSet(viewsets.ModelViewSet): > [ > { > "url": "http://kf.kobo.local/api/v2/organizations/ - orgLRM8xmvWji4itYWWhLVgC/invites/ - f3ba00b2-372b-4283-9d57-adbe7d5b1bf1/", + orgLRM8xmvWji4itYWWhLVgC/invites/f3ba00b2-372b-4283-9d57-adbe7d5b1bf1/", > "invited_by": "http://kf.kobo.local/api/v2/users/raj_patel/", > "status": "pending", > "invitee_role": "member", @@ -513,8 +510,7 @@ class OrgMembershipInviteViewSet(viewsets.ModelViewSet): > }, > { > "url": "http://kf.kobo.local/api/v2/organizations/ - orgLRM8xmvWji4itYWWhLVgC/invites/ - 5e79e0b4-6de4-4901-bbe5-59807fcdd99a/", + orgLRM8xmvWji4itYWWhLVgC/invites/5e79e0b4-6de4-4901-bbe5-59807fcdd99a/", > "invited_by": "http://kf.kobo.local/api/v2/users/raj_patel/", > "status": "pending", > "invitee_role": "member", @@ -524,8 +520,7 @@ class OrgMembershipInviteViewSet(viewsets.ModelViewSet): > }, > { > "url": "http://kf.kobo.local/api/v2/organizations/ - orgLRM8xmvWji4itYWWhLVgC/invites/ - 3efb7217-171f-47a5-9a42-b23055e499d4/", + orgLRM8xmvWji4itYWWhLVgC/invites/3efb7217-171f-47a5-9a42-b23055e499d4/", > "invited_by": "http://kf.kobo.local/api/v2/users/raj_patel/", > "status": "pending", > "invitee_role": "member", @@ -534,6 +529,21 @@ class OrgMembershipInviteViewSet(viewsets.ModelViewSet): > "invitee": "demo20@demo20.com" > } > ] + + ### Delete Organization Invite + + * Organization owner or admin can delete an organization invite. + ++ DELETE /api/v2/organizations/{organization_id}/invites/{invite_guid}/ ++ + > Example + > + > curl -X DELETE https://[kpi]/api/v2/organizations/org_12345/invites/f3ba00b2-372b-4283-9d57-adbe7d5b1bf1/ + + > Response 204 + """ serializer_class = OrgMembershipInviteSerializer permission_classes = [OrgMembershipInvitePermission]