Skip to content

Commit

Permalink
Create endpoints to handle organization invitations
Browse files Browse the repository at this point in the history
  • Loading branch information
rajpatel24 committed Jan 2, 2025
1 parent b6a2829 commit c28ad37
Show file tree
Hide file tree
Showing 20 changed files with 1,030 additions and 10 deletions.
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'


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

0 comments on commit c28ad37

Please sign in to comment.