diff --git a/kobo/apps/organizations/constants.py b/kobo/apps/organizations/constants.py index 790f5984f5..101a65ca18 100644 --- a/kobo/apps/organizations/constants.py +++ b/kobo/apps/organizations/constants.py @@ -1,4 +1,24 @@ +INVITE_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.' +) + +INVITE_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.' +) +INVITE_ALREADY_ACCEPTED_ERROR = 'Invite has already been accepted.' +INVITE_NOT_FOUND_ERROR = 'Invite not found.' 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_and_invitee_role_to_organization_invitation.py b/kobo/apps/organizations/migrations/0010_add_status_and_invitee_role_to_organization_invitation.py new file mode 100644 index 0000000000..431da17637 --- /dev/null +++ b/kobo/apps/organizations/migrations/0010_add_status_and_invitee_role_to_organization_invitation.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.15 on 2025-01-02 12:25 + +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='invitee_role', + field=models.CharField( + choices=[('admin', 'Admin'), ('member', 'Member')], + default='member', + max_length=10, + ), + ), + 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..60eb3e6bf8 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 @@ -24,6 +25,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 +48,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 +288,118 @@ class OrganizationOwner(AbstractOrganizationOwner): class OrganizationInvitation(AbstractOrganizationInvitation): - pass + status = models.CharField( + max_length=11, + choices=OrganizationInviteStatusChoices.choices, + default=OrganizationInviteStatusChoices.PENDING, + ) + invitee_role = models.CharField( + max_length=10, + choices=[('admin', 'Admin'), ('member', 'Member')], + default='member', + ) + + 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): + 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 + organization_name = self.invited_by.organization.name + template_variables = { + 'sender_name': self.invited_by.extra_details.data['name'], + 'sender_username': self.invited_by.username, + 'sender_email': self.invited_by.email, + 'recipient_username': ( + self.invitee.username + if is_registered_user + else self.invitee_identifier + ), + 'recipient_role': self.invitee_role, + 'organization_name': 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: + html_template = 'emails/registered_user_invite.html' + text_template = 'emails/registered_user_invite.txt' + else: + html_template = 'emails/unregistered_user_invite.html' + text_template = 'emails/unregistered_user_invite.txt' + + email_message = EmailMessage( + to=to_email, + subject=t( + f"You're invited to join {organization_name}'s organization" + ), + plain_text_content_or_template=text_template, + template_variables=template_variables, + html_content_or_template=html_template, + language=( + self.invitee.extra_details.data.get('last_ui_language') + if is_registered_user + 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': ( + self.invitee.username + if self.invitee + else self.invitee_identifier + ), + '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') + if self.invitee + else 'en' + ), + ) + + Mailer.send(email_message) create_organization = partial(create_organization_base, model=Organization) diff --git a/kobo/apps/organizations/permissions.py b/kobo/apps/organizations/permissions.py index 02bfe7c1de..39560c74ee 100644 --- a/kobo/apps/organizations/permissions.py +++ b/kobo/apps/organizations/permissions.py @@ -2,7 +2,11 @@ from rest_framework import permissions from rest_framework.permissions import IsAuthenticated -from kobo.apps.organizations.constants import ORG_EXTERNAL_ROLE +from kobo.apps.organizations.constants import ( + ORG_EXTERNAL_ROLE, + ORG_OWNER_ROLE, + ORG_ADMIN_ROLE +) from kobo.apps.organizations.models import Organization from kpi.mixins.validation_password_permission import ValidationPasswordPermissionMixin from kpi.utils.object_permission import get_database_user @@ -58,3 +62,31 @@ def has_object_permission(self, request, view, obj): is validated in `has_permission()`. Therefore, this method always returns True. """ return True + + +class OrgMembershipInvitePermission( + ValidationPasswordPermissionMixin, IsAuthenticated +): + def has_permission(self, request, view): + self.validate_password(request) + if not super().has_permission(request=request, view=view): + return False + + user = get_database_user(request.user) + organization_id = view.kwargs.get('organization_id') + try: + organization = Organization.objects.get(id=organization_id) + except Organization.DoesNotExist: + raise Http404 + + user_role = organization.get_user_role(user) + + # Allow only owners or admins for POST and DELETE + if request.method in ['POST', 'DELETE']: + return user_role in [ORG_OWNER_ROLE, ORG_ADMIN_ROLE] + + # Allow only authenticated users for GET and PATCH + if request.method in ['GET', 'PATCH']: + return True + + return False diff --git a/kobo/apps/organizations/serializers.py b/kobo/apps/organizations/serializers.py index 8194f71118..24e14bc48c 100644 --- a/kobo/apps/organizations/serializers.py +++ b/kobo/apps/organizations/serializers.py @@ -1,21 +1,37 @@ 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, + OrganizationInviteStatusChoices, ) +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, + INVITE_OWNER_ERROR, + INVITE_MEMBER_ERROR, + USER_DOES_NOT_EXIST_ERROR, + INVITE_ALREADY_ACCEPTED_ERROR, + INVITE_NOT_FOUND_ERROR +) +from .tasks import transfer_member_data_ownership_to_org class OrganizationUserSerializer(serializers.ModelSerializer): + invite = serializers.SerializerMethodField() user = serializers.HyperlinkedRelatedField( queryset=get_user_model().objects.all(), lookup_field='username', @@ -47,7 +63,8 @@ class Meta: 'role', 'user__has_mfa_enabled', 'date_joined', - 'user__is_active' + 'user__is_active', + 'invite' ] def get_url(self, obj): @@ -61,6 +78,40 @@ def get_url(self, obj): request=request ) + def get_invite(self, obj): + """ + Get the latest invite for the user if it exists + """ + invite = OrganizationInvitation.objects.filter( + invitee=obj.user + ).order_by('-created').first() + + if invite: + return OrgMembershipInviteSerializer( + invite, context=self.context + ).data + return {} + + def to_representation(self, instance): + """ + Handle representation of invite objects. + + For users who have been invited to an organization but have not yet + registered, we include the invite object and show user object data as null. + """ + if isinstance(instance, OrganizationInvitation): + invite_serializer = OrgMembershipInviteSerializer( + instance, context=self.context + ) + response = {field: None for field in self.Meta.fields} + response.update({ + 'invite': invite_serializer.data, + }) + return response + else: + representation = super().to_representation(instance) + return representation + def update(self, instance, validated_data): if role := validated_data.get('role', None): validated_data['is_admin'] = role == 'admin' @@ -163,3 +214,243 @@ 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() + role = serializers.ChoiceField( + choices=['admin', 'member'], default='member', write_only=True + ) + url = serializers.SerializerMethodField() + + class Meta: + model = OrganizationInvitation + fields = [ + 'url', + 'invited_by', + 'invitees', + 'status', + 'role', + 'invitee_role', + 'created', + '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. + + 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['invitees'] + role = validated_data['role'] + valid_users = invitees['users'] + external_emails = invitees['emails'] + + invites = [] + + # Create invites for existing users + for user in valid_users: + invite = OrganizationInvitation.objects.create( + invited_by=invited_by, + invitee=user, + invitee_role=role, + organization=invited_by.organization, + ) + invites.append(invite) + invite.send_invite_email() + + # Create invites for external emails + for email in external_emails: + invite = OrganizationInvitation.objects.create( + invited_by=invited_by, + invitee_identifier=email, + invitee_role=role, + organization=invited_by.organization, + ) + invites.append(invite) + invite.send_invite_email() + + return invites + + 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 invite + """ + 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 invite 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 invitee'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': t(INVITE_ALREADY_ACCEPTED_ERROR) + } + ) + + # 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': t(INVITE_NOT_FOUND_ERROR)}) + + # 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': t(INVITE_OWNER_ERROR).format( + organization_name=instance.invitee.organization.name + ) + } + ) + else: + raise PermissionDenied( + { + 'detail': t(INVITE_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} diff --git a/kobo/apps/organizations/tasks.py b/kobo/apps/organizations/tasks.py index c9717f023f..c420b6cd33 100644 --- a/kobo/apps/organizations/tasks.py +++ b/kobo/apps/organizations/tasks.py @@ -1,10 +1,21 @@ +from datetime import timedelta + +from constance import config +from django.apps import apps from django.conf import settings +from django.utils import timezone +from django.utils.translation import gettext as t from more_itertools import chunked from kobo.apps.kobo_auth.shortcuts import User +from kobo.apps.organizations.models import ( + Organization, + OrganizationInviteStatusChoices +) from kobo.apps.project_ownership.models import Transfer from kobo.apps.project_ownership.utils import create_invite from kobo.celery import celery_app +from kpi.utils.mailer import EmailMessage, Mailer @celery_app.task( @@ -14,7 +25,12 @@ ) def transfer_member_data_ownership_to_org(user_id: int): sender = User.objects.get(pk=user_id) - recipient = sender.organization.owner_user_object + # 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() + recipient = sender_organization.owner_user_object user_assets = ( sender.assets.only('pk', 'uid') .exclude( @@ -37,3 +53,52 @@ def transfer_member_data_ownership_to_org(user_id: int): assets=asset_batch, invite_class_name='OrgMembershipAutoInvite', ) + + +@celery_app.task +def mark_organization_invite_as_expired(): + Invite = apps.get_model('organizations', 'OrganizationInvitation') + + expiry_threshold = timezone.now() - timedelta( + days=config.ORGANIZATION_INVITE_EXPIRY + ) + + invites_to_update = [] + for invite in Invite.objects.filter( + created__lte=expiry_threshold, + status=OrganizationInviteStatusChoices.PENDING, + ): + invite.status = OrganizationInviteStatusChoices.EXPIRED + invites_to_update.append(invite) + + if not invites_to_update: + return + + Invite.objects.bulk_update(invites_to_update, fields=['status']) + email_messages = [] + + for invite in invites_to_update: + template_variables = { + 'username': invite.invited_by.username, + 'recipient': ( + invite.invitee.username + if invite.invitee + else invite.invitee_identifier + ), + 'organization': invite.organization.name, + 'base_url': settings.KOBOFORM_URL, + } + email_messages.append( + EmailMessage( + to=invite.invited_by.email, + subject=t('Organization invite has expired'), + plain_text_content_or_template='emails/expired_invite.txt', + template_variables=template_variables, + html_content_or_template='emails/expired_invite.html', + language=( + invite.invited_by.extra_details.data.get('last_ui_language') + ) + ) + ) + + Mailer.send(email_messages) 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..d74fd8bc5f --- /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 }} 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..87213ba0be --- /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 }} 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..0c6b041565 --- /dev/null +++ b/kobo/apps/organizations/templates/emails/declined_invite.html @@ -0,0 +1,7 @@ +{% load i18n %} +

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

+ +

{% 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 new file mode 100644 index 0000000000..ef07f0ed63 --- /dev/null +++ b/kobo/apps/organizations/templates/emails/declined_invite.txt @@ -0,0 +1,6 @@ +{% load i18n %} +{% trans "Dear" %} {{ sender_username }}, + +{% 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 new file mode 100644 index 0000000000..3262512b68 --- /dev/null +++ b/kobo/apps/organizations/templates/emails/expired_invite.html @@ -0,0 +1,9 @@ +{% load i18n %} +{% load strings %} + +

{% trans "Dear" %} {{ username }},

+

{% 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 new file mode 100644 index 0000000000..9d956bd04e --- /dev/null +++ b/kobo/apps/organizations/templates/emails/expired_invite.txt @@ -0,0 +1,7 @@ +{% load i18n %} +{% load strings %} + +{% trans "Dear" %} {{ username }}, +{% 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 new file mode 100644 index 0000000000..5dfb2d892d --- /dev/null +++ b/kobo/apps/organizations/templates/emails/registered_user_invite.html @@ -0,0 +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 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 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 new file mode 100644 index 0000000000..8348d664d2 --- /dev/null +++ b/kobo/apps/organizations/templates/emails/registered_user_invite.txt @@ -0,0 +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 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 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 new file mode 100644 index 0000000000..c1ccf926de --- /dev/null +++ b/kobo/apps/organizations/templates/emails/unregistered_user_invite.html @@ -0,0 +1,18 @@ +{% 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 {{recipient_role}} with {{ recipient_username }}.{% endblocktrans %}

+ +

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

+ + +

{% 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 %}

+ +

 - KoboToolbox

diff --git a/kobo/apps/organizations/templates/emails/unregistered_user_invite.txt b/kobo/apps/organizations/templates/emails/unregistered_user_invite.txt new file mode 100644 index 0000000000..fb9247563f --- /dev/null +++ b/kobo/apps/organizations/templates/emails/unregistered_user_invite.txt @@ -0,0 +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 {{recipient_role}} with {{ recipient_username }}. {% 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." %} +* {% 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." %} + +{% 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 %} + +- 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..a4bf695578 --- /dev/null +++ b/kobo/apps/organizations/tests/test_organization_invitations.py @@ -0,0 +1,388 @@ +from ddt import ddt, data, unpack +from constance.test import override_config +from django.core import mail +from django.urls import reverse +from django.db.models import Q +from rest_framework import status + +from kobo.apps.kobo_auth.shortcuts import User +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 +) +from kpi.models import Asset +from kpi.urls.router_api_v2 import URL_NAMESPACE + + +@ddt +class OrganizationInviteTestCase(BaseOrganizationAssetApiTestCase): + fixtures = ['test_data'] + URL_NAMESPACE = URL_NAMESPACE + + def setUp(self): + super().setUp() + 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( + 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_owner_can_send_invitation(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 index, invitation in enumerate(response.data): + self.assertIn( + invitation['invitee'], self.invitation_data['invitees'] + ) + # Check that the email was sent + invite = User.objects.filter( + Q(username=invitation['invitee']) | + Q(email=invitation['invitee']) + ).first() + self.assertEqual( + mail.outbox[index].to[0], + invite.email if invite else invitation['invitee'] + ) + + def test_owner_can_resend_invitation(self): + """ + Test that the organization owner can resend an invitation + """ + self._create_invite(self.owner_user) + invitation = OrganizationInvitation.objects.get( + invitee=self.external_user + ) + response = self.client.patch( + self.detail_url(invitation.guid), data={'status': 'resent'} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], 'resent') + self.assertEqual(mail.outbox[0].to[0], invitation.invitee.email) + + def test_owner_can_cancel_invitation(self): + """ + Test that the organization owner can cancel an invitation + """ + self._create_invite(self.owner_user) + invitation = OrganizationInvitation.objects.get( + invitee=self.external_user + ) + response = self.client.patch( + self.detail_url(invitation.guid), data={'status': 'cancelled'} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], 'cancelled') + + def test_list_invitations(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) + + def test_registered_user_can_accept_invitation(self): + self._create_invite(self.owner_user) + self.client.force_login(self.external_user) + create_asset_response = self._create_asset_by_bob() + 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') + + bob_asset = Asset.objects.get(uid=create_asset_response.data['uid']) + self.assertEqual(bob_asset.owner, self.owner_user) + self.assertEqual(mail.outbox[2].to[0], invitation.invited_by.email) + + def test_registered_user_can_decline_invitation(self): + """ + Test that a registered user can decline an invitation + """ + self._create_invite(self.owner_user) + create_asset_response = self._create_asset_by_bob() + self.client.force_login(self.external_user) + invitation = OrganizationInvitation.objects.get( + invitee=self.external_user + ) + response = self._update_invite( + self.external_user, invitation.guid, 'declined' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], 'declined') + + bob_asset = Asset.objects.get(uid=create_asset_response.data['uid']) + self.assertEqual(bob_asset.owner, self.external_user) + self.assertEqual(mail.outbox[2].to[0], invitation.invited_by.email) + + def test_unregistered_user_can_accept_invitation(self): + """ + 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) + invitation = OrganizationInvitation.objects.get( + invitee_identifier=self.new_user.email + ) + response = self._update_invite( + self.new_user, invitation.guid, 'accepted' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + 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_403_FORBIDDEN), + ) + @unpack + def test_owner_or_admin_can_delete_invitation(self, user_role, expected_status): + """ + Test that the organization owner and admin can delete an invitation + """ + self._create_invite(self.owner_user) + user = getattr(self, f'{user_role}_user') + self.client.force_login(user) + invitation = OrganizationInvitation.objects.get( + invitee=self.external_user + ) + response = self.client.delete(self.detail_url(invitation.guid)) + self.assertEqual(response.status_code, expected_status) + + @override_config(ORGANIZATION_INVITE_EXPIRY=0) + def test_sender_receives_expired_notification(self): + """ + Test that the organization owner receives an email notification + when an invitation expires + """ + OrganizationInvitation.objects.create( + invited_by=self.someuser, + invitee=self.external_user, + organization=self.organization + ) + + mark_organization_invite_as_expired() + + self.assertEqual(mail.outbox[0].to[0], self.someuser.email) + 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/tests/test_organization_members_api.py b/kobo/apps/organizations/tests/test_organization_members_api.py index eb9e74b33b..a8f1843e26 100644 --- a/kobo/apps/organizations/tests/test_organization_members_api.py +++ b/kobo/apps/organizations/tests/test_organization_members_api.py @@ -21,6 +21,9 @@ def setUp(self): self.member_user = self.alice self.admin_user = self.anotheruser self.external_user = self.bob + self.registered_invitee_user = User.objects.create_user( + username='registered_invitee', email='registered_invitee@test.com', + ) self.list_url = reverse( self._get_endpoint('organization-members-list'), @@ -34,6 +37,20 @@ def setUp(self): }, ) + def _create_invite(self, user): + """ + Helper method to create and accept invitations + """ + invitation_data = { + 'invitees': ['registered_invitee', 'unregistered_invitee@test.com'] + } + list_url = reverse( + self._get_endpoint('organization-invites-list'), + kwargs={'organization_id': self.organization.id}, + ) + self.client.force_login(user) + self.client.post(list_url, data=invitation_data) + @data( ('owner', status.HTTP_200_OK), ('admin', status.HTTP_200_OK), @@ -46,11 +63,27 @@ def test_list_members_with_different_roles(self, user_role, expected_status): if user_role == 'anonymous': self.client.logout() else: + self._create_invite(self.someuser) 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) + # Check if the invite data is present for invitees + if response.status_code == status.HTTP_200_OK: + for result in response.data.get('results'): + self.assertIn('invite', result) + if result['user__username'] in [ + 'registered_invitee', 'unregistered_invitee' + ]: + self.assertIn('url', result['invite']) + self.assertIn('invited_by', result['invite']) + self.assertIn('status', result['invite']) + self.assertIn('invitee_role', result['invite']) + self.assertIn('invitee', result['invite']) + self.assertEqual(result['invite']['status'], 'pending') + self.assertEqual(result['invite']['invitee_role'], 'member') + @data( ('owner', status.HTTP_200_OK), ('admin', status.HTTP_200_OK), diff --git a/kobo/apps/organizations/views.py b/kobo/apps/organizations/views.py index 55a55c52c9..3728bdf676 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,23 @@ 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, + OrgMembershipInvitePermission, +) +from .serializers import ( + OrganizationSerializer, + OrganizationUserSerializer, + OrgMembershipInviteSerializer ) -from .serializers import OrganizationSerializer, OrganizationUserSerializer class OrganizationAssetViewSet(AssetViewSet): @@ -283,7 +293,8 @@ class OrganizationMemberViewSet(viewsets.ModelViewSet): > "role": "owner", > "user__has_mfa_enabled": true, > "date_joined": "2024-08-11T12:36:32Z", - > "user__is_active": true + > "user__is_active": true, + > "invite": {} > }, > { > "url": "http://[kpi]/api/v2/organizations/org_12345/ \ @@ -295,8 +306,39 @@ class OrganizationMemberViewSet(viewsets.ModelViewSet): > "role": "admin", > "user__has_mfa_enabled": false, > "date_joined": "2024-10-21T06:38:45Z", - > "user__is_active": true - > } + > "user__is_active": true, + > "invite": { + > "url": "http://[kpi]/api/v2/organizations/org_12345/ + > invites/83c725f1-3f41-4f72-9657-9e6250e130e1/", + > "invited_by": "http://[kpi]/api/v2/users/raj_patel/", + > "status": "accepted", + > "invitee_role": "admin", + > "created": "2024-10-21T05:38:45Z", + > "modified": "2024-10-21T05:40:45Z", + > "invitee": "john_doe" + > } + > }, + > { + > "url": null, + > "user": null, + > "user__username": null, + > "user__email": "null, + > "user__extra_details__name": "null, + > "role": null, + > "user__has_mfa_enabled": null, + > "date_joined": null, + > "user__is_active": null, + > "invite": { + > "url": "http://[kpi]/api/v2/organizations/org_12345/ + > invites/83c725f1-3f41-4f72-9657-9e6250e130e1/", + > "invited_by": "http://[kpi]/api/v2/users/raj_patel/", + > "status": "pending", + > "invitee_role": "admin", + > "created": "2025-01-07T09:03:50Z", + > "modified": "2025-01-07T09:03:50Z", + > "invitee": "demo" + > } + > }, > ] > } @@ -403,7 +445,7 @@ def get_queryset(self): # Subquery to check if the user is the owner owner_subquery = OrganizationOwner.objects.filter( - organization_id=organization_id, + organization_id=OuterRef('organization_id'), organization_user=OuterRef('pk') ).values('pk') @@ -419,4 +461,195 @@ def get_queryset(self): ), has_mfa_enabled=Exists(mfa_subquery) ) + + if self.action == 'list': + # Include invited users who are not yet part of this organization + invitation_queryset = OrganizationInvitation.objects.filter( + organization_id=organization_id, status='pending' + ) + + # Queryset for invited users who have registered + registered_invitees = OrganizationUser.objects.filter( + user_id__in=invitation_queryset.values('invitee_id') + ).select_related('user__extra_details').annotate( + role=Case( + When(Exists(owner_subquery), then=Value('owner')), + When(is_admin=True, then=Value('admin')), + default=Value('member'), + output_field=CharField() + ), + has_mfa_enabled=Exists(mfa_subquery) + ) + + # Queryset for invited users who have not yet registered + unregistered_invitees = invitation_queryset.filter( + invitee_id__isnull=True + ) + + queryset = ( + list(queryset) + + list(registered_invitees) + + list(unregistered_invitees) + ) return queryset + + +class OrgMembershipInviteViewSet(viewsets.ModelViewSet): + """ + ### List Organization Invites + +
+    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/ + org_12345/invites/f361ebf6-d1c1-4ced-8343-04b11863d784/", + > "invited_by": "http://kf.kobo.local/api/v2/users/demo7/", + > "status": "pending", + > "invitee_role": "member", + > "created": "2024-12-11T16:00:00Z", + > "modified": "2024-12-11T16:00:00Z", + > "invitee": "raj_patel" + > }, + > { + > "url": "http://kf.kobo.local/api/v2/organizations/ + org_12345/invites/1a8b93bf-eec5-4e56-bd4a-5f7657e6a2fd/", + > "invited_by": "http://kf.kobo.local/api/v2/users/raj_patel/", + > "status": "pending", + > "invitee_role": "member", + > "created": "2024-12-11T18:19:56Z", + > "modified": "2024-12-11T18:19:56Z", + > "invitee": "demo7" + > }, + > ] + > } + + ### Create Organization Invite + + * Create organization invites for registered and unregistered users. + * Set the role for which the user is being invited - + (Choices: `member`, `admin`). Default is `member`. + +
+    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"] + > "role": "member" + > } + + > Response 200 + + > [ + > { + > "url": "http://kf.kobo.local/api/v2/organizations/ + org_12345/invites/f3ba00b2-372b-4283-9d57-adbe7d5b1bf1/", + > "invited_by": "http://kf.kobo.local/api/v2/users/raj_patel/", + > "status": "pending", + > "invitee_role": "member", + > "created": "2024-12-20T13:35:13Z", + > "modified": "2024-12-20T13:35:13Z", + > "invitee": "demo14" + > }, + > { + > "url": "http://kf.kobo.local/api/v2/organizations/ + org_12345/invites/5e79e0b4-6de4-4901-bbe5-59807fcdd99a/", + > "invited_by": "http://kf.kobo.local/api/v2/users/raj_patel/", + > "status": "pending", + > "invitee_role": "member", + > "created": "2024-12-20T13:35:13Z", + > "modified": "2024-12-20T13:35:13Z", + > "invitee": "demo13" + > }, + > { + > "url": "http://kf.kobo.local/api/v2/organizations/ + org_12345/invites/3efb7217-171f-47a5-9a42-b23055e499d4/", + > "invited_by": "http://kf.kobo.local/api/v2/users/raj_patel/", + > "status": "pending", + > "invitee_role": "member", + > "created": "2024-12-20T13:35:13Z", + > "modified": "2024-12-20T13:35:13Z", + > "invitee": "demo20@demo20.com" + > } + > ] + + ### Update Organization Invite + + * Update an organization invite to accept, decline, cancel, expire, or resend. + +
+    PATCH /api/v2/organizations/{organization_id}/invites/{invite_guid}/
+    
+ + > Example + > + > curl -X PATCH https://[kpi]/api/v2/organizations/org_12345/invites/f3ba00b2-372b-4283-9d57-adbe7d5b1bf1/ + + > Response 200 + + > { + > "url": "http://kf.kobo.local/api/v2/organizations/ + org_12345/invites/f3ba00b2-372b-4283-9d57-adbe7d5b1bf1/", + > "invited_by": "http://kf.kobo.local/api/v2/users/raj_patel/", + > "status": "accepted", + > "invitee_role": "member", + > "created": "2024-12-20T13:35:13Z", + > "modified": "2024-12-20T13:35:13Z", + > "invitee": "demo14" + > } + + ### 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] + http_method_names = ['get', 'post', 'patch', 'delete'] + lookup_field = 'guid' + + def get_queryset(self): + organization_id = self.kwargs['organization_id'] + return OrganizationInvitation.objects.select_related( + 'invitee', 'invited_by', 'organization' + ).filter(organization_id=organization_id) + + 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 invites + serializer = OrgMembershipInviteSerializer( + invitations, many=True, context={'request': request} + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) diff --git a/kobo/settings/base.py b/kobo/settings/base.py index e1108a26d3..b910e18c5e 100644 --- a/kobo/settings/base.py +++ b/kobo/settings/base.py @@ -396,6 +396,11 @@ "Options available for the 'operational purpose of data' metadata " 'field, one per line.' ), + 'ORGANIZATION_INVITE_EXPIRY': ( + 14, + 'Number of days before organization invites expire.', + 'positive_int', + ), 'ASSET_SNAPSHOT_DAYS_RETENTION': ( 30, 'Number of days to keep asset snapshots', @@ -669,7 +674,8 @@ 'FRONTEND_MAX_RETRY_TIME', 'USE_TEAM_LABEL', 'ACCESS_LOG_LIFESPAN', - 'PROJECT_HISTORY_LOG_LIFESPAN' + 'PROJECT_HISTORY_LOG_LIFESPAN', + 'ORGANIZATION_INVITE_EXPIRY' ), 'Rest Services': ( 'ALLOW_UNSECURED_HOOK_ENDPOINTS', @@ -1214,6 +1220,12 @@ def dj_stripe_request_callback_method(): 'schedule': crontab(hour=0, minute=0), 'options': {'queue': 'kobocat_queue'} }, + # Schedule every 30 minutes + 'organization-invite-mark-as-expired': { + 'task': 'kobo.apps.organizations.tasks.mark_organization_invite_as_expired', + 'schedule': crontab(minute=30), + 'options': {'queue': 'kpi_low_priority_queue'} + }, # Schedule every 10 minutes 'project-ownership-task-scheduler': { 'task': 'kobo.apps.project_ownership.tasks.task_rescheduler', 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)