Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat(organizations): create endpoints to handle organization invitations #5395

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions kobo/apps/organizations/constants.py
Original file line number Diff line number Diff line change
@@ -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.'
Original file line number Diff line number Diff line change
@@ -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,
),
),
]
121 changes: 120 additions & 1 deletion kobo/apps/organizations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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'
Copy link
Member

@magicznyleszek magicznyleszek Jan 4, 2025

Choose a reason for hiding this comment

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

@rajpatel24 What's the difference between in_progress and pending?

Edit: looking more at this list, I think it would be very useful if you'd describe each of these statuses - like what action triggers them

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@magicznyleszek As of now, we're not using in_progress as a status for organization invitations. I included it as a possible choice, along with completed and failed, in case the need arises in the future. Once @noliveleger is back (probably today), I'll discuss this with him. Currently, when the organization owner sends an invitation, the status is set to pending. When the invitee accepts the invitation, the status changes to accepted in the OrganizationInvitation table.

Additionally, when a user accepts an invitation, we trigger an async task to transfer their projects to the organization. This is part of the existing functionality we're reusing for project transfers along with organization invitations. It uses its own project-ownership/Invite table with different statuses, which you might already be familiar with.

So, my question for @noliveleger would be: when the project transfer is completed and the status in the project-ownership/Invite table is set to completed, do we also need to set the status to completed in the OrganizationInvitation table, or is the accepted state sufficient?

To summarize, the /api/v2/organizations/:organization_id/invites/ API will currently have the following statuses: pending, accepted, declined, cancelled, expired, and resent.

If any changes occur regarding this, I'll make sure to update you right away.

Copy link
Contributor

Choose a reason for hiding this comment

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

@rajpatel24 , accepted would be enough for OrganizationInvitation.



class Organization(AbstractOrganization):
id = KpiUidField(uid_prefix='org', primary_key=True)
mmo_override = models.BooleanField(
Expand Down Expand Up @@ -273,7 +287,112 @@ 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):
"""
Sends an email to invite a user to join a team as an admin.
"""
is_registered_user = bool(self.invitee)
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
),
'organization_name': self.invited_by.organization.name,
'base_url': settings.KOBOFORM_URL,
'invite_uid': self.guid,
'is_registered_user': is_registered_user,
}

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=(
self.invitee.email
if is_registered_user
else self.invitee_identifier
),
subject='Invitation to Join the 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)
34 changes: 33 additions & 1 deletion kobo/apps/organizations/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading
Loading