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 7 commits into
base: main
Choose a base branch
from
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}. '
Copy link
Contributor

Choose a reason for hiding this comment

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

By convention, we are using ##placeholder## to let the translators know it is a placeholder and it should not be translated.

As per our discussion, @rajpatel24 may create an utility function to replace placeholders (instead of calling .replace().replace().etc)

'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'
noliveleger marked this conversation as resolved.
Show resolved Hide resolved


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'),
Copy link
Contributor

Choose a reason for hiding this comment

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

You probably need to call translation.activate(<language>) to be sure the correct language is loaded.

sender_language = self.invited_by.extra_details.data.get('last_ui_language', DEFAULT_LANGUAGE)
translation.activate(sender_language) 

As per our internal discussion:
last_ui_language was used only for reports.

we should make sure it's clear in the code that we use that attribute for more than just reports

Please add a comment in ExtraUserDetail that it is also used to translate (email) templates in the user's language.

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
):
Comment on lines +67 to +69
Copy link
Contributor

Choose a reason for hiding this comment

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

During QA, anybody was able to see all invites from an org.

  • Admins and owner should be able to see all their org invites
  • Others should see all the invites related to them

Nobody should be able to see invites of other orgs.

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