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

Conversation

rajpatel24
Copy link
Contributor

@rajpatel24 rajpatel24 commented Dec 20, 2024

🗒️ Checklist

  1. run linter locally
  2. update all related docs (API, README, inline, etc.), if any
  3. draft PR with a title <type>(<scope>)<!>: <title> TASK-1234
  4. tag PR: at least frontend or backend unless it's global
  5. fill in the template below and delete template comments
  6. review thyself: read the diff and repro the preview as written
  7. open PR & confirm that CI passes
  8. request reviewers, if needed
  9. delete this section before merging

📣 Summary

Implemented endpoints for organization invitations, allowing organization owners to invite existing users or unregistered users to join their organization. The invitee can either accept or decline the invitation. If the invitee accepts, their assets will be transferred to the organization.

📖 Description

  • Organization owners can send invitations to users (both registered and unregistered) via email or username.
  • The invitee can accept or decline the invitation. If accepted, the invitee's assets will be transferred to the organization owner.

POST https://[kpi]/api/v2/organizations/org_12345/invites/

  • Create organization invites for registered and unregistered users.
  • Set the role for which the user is being invited - (Choices: member, admin). Default is member.

Payload:

{
    "invitees": ["demo14", "demo13@demo13.com", "demo25@demo25.com"],
    "role": "admin"
}

Response:

[
    {
        "url": "http://kf.kobo.local/api/v2/organizations/org_12345/invites/7a4f9a3b-9112-43cc-a6ae-bb4a6583b4b2/",
        "invited_by": "http://kf.kobo.local/api/v2/users/gtl_raj/",
        "status": "pending",
        "invitee_role": "admin",
        "created": "2025-01-06T13:01:40Z",
        "modified": "2025-01-06T13:01:40Z",
        "invitee": "demo14"
    },
    {
        "url": "http://kf.kobo.local/api/v2/organizations/org_12345/invites/6746121a-7a87-4c2d-9994-85e38d8cff65/",
        "invited_by": "http://kf.kobo.local/api/v2/users/gtl_raj/",
        "status": "pending",
        "invitee_role": "admin",
        "created": "2025-01-06T13:01:40Z",
        "modified": "2025-01-06T13:01:40Z",
        "invitee": "demo13"
    },
    {
        "url": "http://kf.kobo.local/api/v2/organizations/org_12345/invites/2af706a9-4f67-4145-a0d3-8d66fbc77a19/",
        "invited_by": "http://kf.kobo.local/api/v2/users/gtl_raj/",
        "status": "pending",
        "invitee_role": "admin",
        "created": "2025-01-06T13:01:40Z",
        "modified": "2025-01-06T13:01:40Z",
        "invitee": "demo25@demo25.com"
    }
]

GET https://[kpi]/api/v2/organizations/org_12345/invites/

Response:

{
    "count": 3,
    "next": null,
    "previous": null,
    "results": [
        {
            "url": "http://kf.kobo.local/api/v2/organizations/org_12345/invites/6746121a-7a87-4c2d-9994-85e38d8cff65/",
            "invited_by": "http://kf.kobo.local/api/v2/users/gtl_raj/",
            "status": "pending",
            "invitee_role": "admin",
            "created": "2025-01-06T13:01:40Z",
            "modified": "2025-01-06T13:01:40Z",
            "invitee": "demo13"
        },
        {
            "url": "http://kf.kobo.local/api/v2/organizations/org_12345/invites/7a4f9a3b-9112-43cc-a6ae-bb4a6583b4b2/",
            "invited_by": "http://kf.kobo.local/api/v2/users/gtl_raj/",
            "status": "pending",
            "invitee_role": "admin",
            "created": "2025-01-06T13:01:40Z",
            "modified": "2025-01-06T13:01:40Z",
            "invitee": "demo14"
        },
        {
            "url": "http://kf.kobo.local/api/v2/organizations/org_12345/invites/2af706a9-4f67-4145-a0d3-8d66fbc77a19/",
            "invited_by": "http://kf.kobo.local/api/v2/users/gtl_raj/",
            "status": "pending",
            "invitee_role": "admin",
            "created": "2025-01-06T13:01:40Z",
            "modified": "2025-01-06T13:01:40Z",
            "invitee": "demo25@demo25.com"
        }
    ]
}

PATCH https://[kpi]/api/v2/organizations/org_12345/invites/7a4f9a3b-9112-43cc-a6ae-bb4a6583b4b2/

  • Update an organization invite to accept, decline, cancel, expire, or resend.

Payload:

{
    "status": "accepted"
}

Response:

{
    "url": "http://kf.kobo.local/api/v2/organizations/org_12345/invites/7a4f9a3b-9112-43cc-a6ae-bb4a6583b4b2/",
    "invited_by": "http://kf.kobo.local/api/v2/users/gtl_raj/",
    "status": "accepted",
    "invitee_role": "admin",
    "created": "2025-01-06T13:01:40Z",
    "modified": "2025-01-06T13:01:40Z",
    "invitee": "demo14"
}

DELETE https://[kpi]/api/v2/organizations/org_12345/invites/7a4f9a3b-9112-43cc-a6ae-bb4a6583b4b2/

  • Organization owner or admin can delete an organization invite.

Response: 204

@rajpatel24 rajpatel24 force-pushed the task-969-create-endpoints-to-handle-org-invitations branch from 092d932 to 64a2b55 Compare December 30, 2024 15:46
@rajpatel24 rajpatel24 changed the title Create endpoints to handle organization invitations feat(organizations): create endpoints to handle organization invitations Dec 30, 2024
@rajpatel24 rajpatel24 marked this pull request as ready for review December 30, 2024 16:14
@rajpatel24 rajpatel24 removed the request for review from jnm December 30, 2024 16:14
Copy link

Copy link

Copy link

@rajpatel24 rajpatel24 force-pushed the task-969-create-endpoints-to-handle-org-invitations branch from 64a2b55 to 1139076 Compare January 1, 2025 15:45
@rajpatel24 rajpatel24 self-assigned this Jan 1, 2025
@rajpatel24 rajpatel24 force-pushed the task-969-create-endpoints-to-handle-org-invitations branch from 1139076 to c28ad37 Compare January 2, 2025 14:20
@magicznyleszek
Copy link
Member

magicznyleszek commented Jan 4, 2025

@rajpatel24 Wuld that whole invite object be present in /api/v2/organizations/:organization_id/members/ in the invite property? And I see you have pending status in there, and is this the same as sent?

@rajpatel24
Copy link
Contributor Author

@rajpatel24 Wuld that whole invite object be present in /api/v2/organizations/:organization_id/members/ in the invite property? And I see you have pending status in there, and is this the same as sent?

@magicznyleszek No, the invite object would not be present there. I don't think it's necessary since /api/v2/organizations/:organization_id/members/ is designed to list the members who are already part of the organization - those who have accepted the invitations and are now part of it.

The pending status is the same as sent. When the organization owner sends an invitation, the status of the invite will be set to pending.

@rajpatel24 rajpatel24 force-pushed the task-969-create-endpoints-to-handle-org-invitations branch from 31ac077 to ea9a6bd Compare January 6, 2025 13:15
@rajpatel24 rajpatel24 force-pushed the task-969-create-endpoints-to-handle-org-invitations branch from 32b1431 to 5394358 Compare January 7, 2025 15:37
Comment on lines +291 to +293
def create(self, validated_data):
"""
Create multiple invitations for the provided invitees.
Copy link
Contributor

Choose a reason for hiding this comment

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

move it before underscore prefixed methods.

Comment on lines +28 to +33
# 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
Copy link
Contributor

Choose a reason for hiding this comment

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

How can it be outdated since the cache only lives during the request?
Can you provide some examples to reproduce the issue?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm updating the user's organization during the PATCH request (when the invitee accepts the invitation). However, after updating the user's organization, when I try to fetch the organization using the organization property, it doesn't return the updated value. It's working fine if we remove the @cache_for_request decorator. I've tried several ways to update the cached value but haven't been successful.

Copy link
Contributor

Choose a reason for hiding this comment

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

I see, can you try to use void_cache_for_request() decorator somewhere, somehow to update sender.organization? Your solution does work but it means somewhere we could still call sender.organization and organization would not the correct one (until the request ends).

kobo/apps/organizations/models.py Show resolved Hide resolved

{% 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." %}
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think this sentence should be here since that's the sender who receives this message.
(Please fix HTML template too)

@@ -0,0 +1,18 @@
{% load i18n %}
{% trans "Hello," %}
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's be a little more fancy,
If email has been sent to a username, let's use: Hello <username>
if email has been sent to a valid email with only one account, let's use: Hello <username>
if email has been sent to a valid email with more than one account, let's use: Hello <email> .

Then get rid of (username: {{ recipient_username }}) in block below

What happens if invitation is sent to an email with multiple account? I don't see the template for that? The text is little bit different

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure, I've updated the template accordingly.

Regarding the template for multiple accounts, it is included in the same template with the has_multiple_accounts condition. I’ve also updated some text for it.

@@ -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)


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.

# Get recipient role with an article
recipient_role = self.invitee_role
if recipient_role and recipient_role[0].lower() in 'aeiou':
recipient_role = 'an ' + recipient_role
Copy link
Contributor

Choose a reason for hiding this comment

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

Should be the whole string to translate it.

recipient_role = t('an admin') if self.invitee_role === 'admin' else t('a member')
``

Comment on lines +67 to +69
class OrgMembershipInvitePermission(
ValidationPasswordPermissionMixin, IsAuthenticated
):
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.

Comment on lines +28 to +33
# 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
Copy link
Contributor

Choose a reason for hiding this comment

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

I see, can you try to use void_cache_for_request() decorator somewhere, somehow to update sender.organization? Your solution does work but it means somewhere we could still call sender.organization and organization would not the correct one (until the request ends).

self.client.force_login(user)
return self.client.patch(self.detail_url(guid), data={'status': status})

def test_owner_can_send_invitation(self):
Copy link
Contributor

Choose a reason for hiding this comment

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

We need to test that owner and admins can send invitations but no one else. (member and anonymous).

self.assertEqual(response.data['status'], 'resent')
self.assertEqual(mail.outbox[0].to[0], invitation.invitee.email)

def test_owner_can_cancel_invitation(self):
Copy link
Contributor

Choose a reason for hiding this comment

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

Same comment, we need to test every role just to be sure.

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['status'], 'cancelled')

def test_list_invitations(self):
Copy link
Contributor

Choose a reason for hiding this comment

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

We need also to test someone from OrganizationA cannot list invitations from OrganizationB (if they don't belong to it).

serializer_class = OrgMembershipInviteSerializer
permission_classes = [OrgMembershipInvitePermission]
http_method_names = ['get', 'post', 'patch', 'delete']
lookup_field = 'guid'
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's see if we can use our KpiUidField (this comment should in the model :-P) instead of this guid to be consistent with other fields. If cannot be replaced, no need to have two different fields which do the same thing.

'invitee', 'invited_by', 'organization'
).filter(organization_id=organization_id)

def create(self, request, *args, **kwargs):
Copy link
Contributor

Choose a reason for hiding this comment

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

a->z :-)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants