-
-
Notifications
You must be signed in to change notification settings - Fork 185
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
base: main
Are you sure you want to change the base?
Changes from 1 commit
c28ad37
af29b03
ea9a6bd
5394358
93eb306
1674881
c230f54
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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, | ||
), | ||
), | ||
] |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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, | ||
|
@@ -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( | ||
|
@@ -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'), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You probably need to call sender_language = self.invited_by.extra_details.data.get('last_ui_language', DEFAULT_LANGUAGE)
translation.activate(sender_language) As per our internal discussion:
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) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
): | ||
Comment on lines
+67
to
+69
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. During QA, anybody was able to see all invites from an org.
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 |
There was a problem hiding this comment.
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)