Skip to content

Commit ea3ac4f

Browse files
Add ability to create, update project invitations (#2430)
* add ProjectInvitation model model to store a project invitation details * APIRequestFactory test * add tests for get project invitations list * add tests for get project invitations list * add create project invitation endpoint * add tests for create project invitation endpoint * update project invitation role * add endpoint to revoke project invitation * add endpoint to resend project invitation * update comments * restore onadata/libs/filters.py * make project invitation status readonly have status field in create project invitation endpoint readonly * add project invitation endpoints documentation add API documentation remove project key from API response * format project invitations documentation * format project invitations documentation * format project invitations documentation * format project invitations documentation * format project invitations documentation * update path for revoke, resend project invitation * expose ProjectInvitation model to Django admin * revert changes to expose ProjectInvitation in Django admin * fix lint errors fix lint errors for file onadata/libs/serializers/project_invitation_serializer * fix lint errors fix lint errors for file onadata/apps/logger/models/project_invitation.py * fix cylic dependency * fix linting errors * refactor code * add code comments * refactor code * suppress linting error suppress linting error abstract-method / Method 'create' is abstract in class 'BaseSerializer' but is not overridden * remove duplicate variable declaration * separate update project invitation from create * add test case to update project invitation * fix typo in docs * fix typo in docs * Send and accept project invitation (#2443) * send project invitation email * add tests for ProjectInvitationEmailTestCase and refactor * accept project invitation accept all pending project invitations when user creates account * have invitation_id, invitation_token as query params change invitation_id, invitation_token from being submitted as part of the payload but instead received from query params * add tests for tasks add tests for send_project_invitation_email_async, accept_project_invitation_async * add documentation for accept project invitation * enhance project invitation docs * enhance project invitation docs * update method docstring * update method docstring * fix rst typos in docs * fix rst typos in docs * fix rst typos in docs * fix rst typos in docs * add fields invited_by, accepted_by for ProjectInvitation * remove unused code * update docs * add test case * provide flexibility to add extra context data to invitation email templates * catch exceptions * refactor code * refactor code * fix linting error * fix linting errors * fix linting erros * fix linting erros * fix linting errors * fix linting errors * fix linting errors * fix linting errors * fix linting errors * Update invitations url path Signed-off-by: Kipchirchir Sigei <[email protected]> * Fix typon in invitations endpoint methods Signed-off-by: Kipchirchir Sigei <[email protected]> * Cleanup Signed-off-by: Kipchirchir Sigei <[email protected]> * remove HTML ampersand character from invitation mail * remove unique together ProjectInvitation model there can be multiple revoked invitations. To support this, unique together integrity check has been removed. To prevent duplicate invitations from being created, a validation check has been added to the create invitation endpoint * refactor code * add temporary logging for debugging * log temporarily for debugging * log temporarily for debugging * log temp for debuggig * remove debugging logs * fix linting error add missing method docstring * share projects if invitation invalid/missing If id and token are invalid or are not provided but the user registers using an email that matches a pending invitation, then that project is shared with the user. * refactor code * fix failing test fix failing test remove PATCH support endpoint /api/v1/projects/{pk}/invitations update documentation * update documentatio * update documentation * fix bug when working with multipart/formdata * fix typo in docs * fix Invitation already exists when updating invitation when the email does not change when updating invitation, the error 'Invitation already exists' occurred. The fix was to have the check for uniqueness only when creating * fix 'User already exists' when updating an accepted invitation ensure only pending invitations can be updated * send project invtation email when email is updated * fix typo * Only accept project invitations whose email match new user email (#2449) * remove project invitation id and token verification remove invitation_id and invitation_token query params from invitation email link. remove support for allowing a user to register using a different email from the one the invite was sent to add a post_save signal to accept only invitations that match the new user email and remove implementation for accepting invitation from the UserProfileSerializer. This is because a user can also be created using OIDC * update project invitation documentation * fix linting errors * fix error when creating user with no password fix AttributeError: 'NoneType' object has no attribute 'lower' when creating a user with password field missing from the payload * validate password if not None when creating user * refactor cod * use queryset_iterator to iterate queryset --------- Signed-off-by: Kipchirchir Sigei <[email protected]> Co-authored-by: Kipchirchir Sigei <[email protected]>
1 parent caf1fef commit ea3ac4f

23 files changed

+2023
-97
lines changed

docs/projects.rst

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Where:
88
- ``pk`` - is the project id
99
- ``formid`` - is the form id
1010
- ``owner`` - is the username for the user or organization of the project
11+
- ``invitation_pk`` - is the project invitation id
1112

1213
Register a new Project
1314
-----------------------
@@ -515,3 +516,239 @@ Get user profiles that have starred a project
515516

516517
<pre class="prettyprint">
517518
<b>GET</b> /api/v1/projects/<code>{pk}</code>/star</pre>
519+
520+
Get Project Invitation List
521+
---------------------------
522+
523+
.. raw:: html
524+
525+
<pre class="prettyprint"><b>GET</b> /api/v1/projects/{pk}/invitations</pre>
526+
527+
Example
528+
^^^^^^^
529+
530+
::
531+
532+
curl -X GET https://api.ona.io/api/v1/projects/1/invitations
533+
534+
Response
535+
^^^^^^^^
536+
537+
::
538+
539+
[
540+
{
541+
"id": 1,
542+
"email":"[email protected]",
543+
"role":"readonly",
544+
"status": 1
545+
546+
},
547+
{
548+
"id": 2,
549+
"email":"[email protected]",
550+
"role":"editor",
551+
"status": 2,
552+
}
553+
]
554+
555+
Get a list of project invitations with a specific status
556+
--------------------------------------------------------
557+
558+
The available choices are:
559+
560+
- ``1`` - Pending. Invitations which have not been accepted by recipients.
561+
- ``2`` - Accepted. Invitations which have been accepted by recipients.
562+
- ``3`` - Revoked. Invitations which were cancelled.
563+
564+
565+
.. raw:: html
566+
567+
<pre class="prettyprint"><b>GET</b> /api/v1/projects/{pk}/invitations?status=2</pre>
568+
569+
570+
Example
571+
^^^^^^^
572+
573+
::
574+
575+
curl -X GET https://api.ona.io/api/v1/projects/1/invitations?status=2
576+
577+
Response
578+
^^^^^^^^
579+
580+
::
581+
582+
[
583+
584+
{
585+
"id": 2,
586+
"email":"[email protected]",
587+
"role":"editor",
588+
"status": 2,
589+
}
590+
]
591+
592+
593+
Create a new project invitation
594+
-------------------------------
595+
596+
Invite an **unregistered** user to a project. An email will be sent to the user which has a link for them to
597+
create an account.
598+
599+
.. raw:: html
600+
601+
<pre class="prettyprint"><b>POST</b> /api/v1/projects/{pk}/invitations</pre>
602+
603+
Example
604+
^^^^^^^
605+
606+
::
607+
608+
curl -X POST -d "[email protected]" -d "role=readonly" https://api.ona.io/api/v1/projects/1/invitations
609+
610+
611+
``email``: The email address of the unregistered user.
612+
613+
- Should be a valid email. If the ``PROJECT_INVITATION_EMAIL_DOMAIN_WHITELIST`` setting has been enabled, then the email domain has to be in the whitelist for it to be also valid
614+
615+
**Example**
616+
617+
::
618+
619+
PROJECT_INVITATION_EMAIL_DOMAIN_WHITELIST=["foo.com", "bar.com"]
620+
621+
- Email should not be that of a registered user
622+
623+
``role``: The user's role for the project.
624+
625+
- Must be a valid role
626+
627+
628+
Response
629+
^^^^^^^^
630+
631+
::
632+
633+
{
634+
"id": 1,
635+
"email": "[email protected]",
636+
"role": "readonly",
637+
"status": 1,
638+
}
639+
640+
641+
The link embedded in the email will be of the format ``http://{url}``
642+
where:
643+
644+
- ``url`` - is the URL the recipient will be redirected to on clicking the link. The default is ``{domain}/api/v1/profiles`` where ``domain`` is domain where the API is hosted.
645+
646+
Normally, you would want the email recipient to be redirected to a web app. This can be achieved by
647+
adding the setting ``PROJECT_INVITATION_URL``
648+
649+
**Example**
650+
651+
::
652+
653+
PROJECT_INVITATION_URL = 'https://example.com/register'
654+
655+
656+
Update a project invitation
657+
---------------------------
658+
659+
.. raw:: html
660+
661+
<pre class="prettyprint">
662+
<b>PUT</b> /api/v1/projects/{pk}/invitations
663+
</pre>
664+
665+
666+
Example
667+
^^^^^^^
668+
669+
::
670+
671+
curl -X PUT -d "[email protected]" -d "role=editor" -d "invitation_id=1" https://api.ona.io/api/v1/projects/1/invitations/1
672+
673+
Response
674+
^^^^^^^^
675+
676+
::
677+
678+
{
679+
"id": 1,
680+
"email": "[email protected]",
681+
"role": "editor",
682+
"status": 1,
683+
}
684+
685+
686+
Resend a project invitation
687+
---------------------------
688+
689+
Resend a project invitation email
690+
691+
.. raw:: html
692+
693+
<pre class="prettyprint"><b>POST</b> /api/v1/projects/{pk}/resend-invitation</pre>
694+
695+
Example
696+
^^^^^^^
697+
698+
::
699+
700+
curl -X POST -d "invitation_id=6" https://api.ona.io/api/v1/projects/1/resend-invitation
701+
702+
703+
``invitation_id``: The primary key of the ``ProjectInvitation`` to resend.
704+
705+
- Must be a ``ProjectInvitation`` whose status is **Pending**
706+
707+
Response
708+
^^^^^^^^
709+
710+
::
711+
712+
{
713+
"message": "Success"
714+
}
715+
716+
Revoke a project invitation
717+
---------------------------
718+
719+
Cancel a project invitation. A revoked invitation means that project will **not** be shared with the new user
720+
even if they accept the invitation.
721+
722+
.. raw:: html
723+
724+
<pre class="prettyprint"><b>POST</b> /api/v1/projects/{pk}/revoke-invitation</pre>
725+
726+
Example
727+
^^^^^^^
728+
729+
::
730+
731+
curl -X POST -d "invitation_id=6" https://api.ona.io/api/v1/projects/1/revoke-invitation
732+
733+
``invitation_id``: The primary key of the ``ProjectInvitation`` to resend.
734+
735+
- Must be a ``ProjectInvitation`` whose status is **Pending**
736+
737+
Response
738+
^^^^^^^^
739+
740+
::
741+
742+
{
743+
"message": "Success"
744+
}
745+
746+
747+
Accept a project invitation
748+
---------------------------
749+
750+
Since a project invitation is sent to an unregistered user, acceptance of the invitation is handled
751+
when `creating a new user <https://github.com/onaio/onadata/blob/main/docs/profiles.rst#register-a-new-user>`_.
752+
753+
All pending invitations whose email match the new user's email will be accepted and projects shared with the
754+
user

onadata/apps/api/permissions.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,6 @@ def has_permission(self, request, view):
191191
is_authenticated = request and request.user.is_authenticated
192192

193193
if is_authenticated and view.action == "create":
194-
195194
# Handle bulk create
196195
# if doing a bulk create we will fail the entire process if the
197196
# user lacks permissions for even one instance
@@ -278,6 +277,12 @@ def has_object_permission(self, request, view, obj):
278277
if remove and request.user.username.lower() == username.lower():
279278
return True
280279

280+
if view.action == "invitations" and not (
281+
ManagerRole.user_has_role(request.user, obj)
282+
or OwnerRole.user_has_role(request.user, obj)
283+
):
284+
return False
285+
281286
return super().has_object_permission(request, view, obj)
282287

283288

@@ -306,7 +311,6 @@ def has_permission(self, request, view):
306311
and (request.user.is_authenticated or not self.authenticated_users_only)
307312
and request.user.has_perms(perms)
308313
):
309-
310314
return True
311315

312316
return False
@@ -387,7 +391,6 @@ class UserViewSetPermissions(DjangoModelPermissionsOrAnonReadOnly):
387391
"""
388392

389393
def has_permission(self, request, view):
390-
391394
if request.user.is_anonymous and view.action == "list":
392395
if request.GET.get("search"):
393396
raise exceptions.NotAuthenticated()

onadata/apps/api/tasks.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"""
55
import os
66
import sys
7+
import logging
78
from datetime import timedelta
89

910
from celery.result import AsyncResult
@@ -17,7 +18,8 @@
1718
from onadata.apps.api import tools
1819
from onadata.libs.utils.email import send_generic_email
1920
from onadata.libs.utils.model_tools import queryset_iterator
20-
from onadata.apps.logger.models import Instance, XForm
21+
from onadata.apps.logger.models import Instance, ProjectInvitation, XForm
22+
from onadata.libs.utils.email import ProjectInvitationEmail
2123
from onadata.celeryapp import app
2224

2325
User = get_user_model()
@@ -127,3 +129,19 @@ def delete_inactive_submissions():
127129
for instance in queryset_iterator(instances):
128130
# delete submission
129131
instance.delete()
132+
133+
134+
@app.task()
135+
def send_project_invitation_email_async(
136+
invitation_id: str, url: str
137+
): # pylint: disable=invalid-name
138+
"""Sends project invitation email asynchronously"""
139+
try:
140+
invitation = ProjectInvitation.objects.get(id=invitation_id)
141+
142+
except ProjectInvitation.DoesNotExist as err:
143+
logging.exception(err)
144+
145+
else:
146+
email = ProjectInvitationEmail(invitation, url)
147+
email.send()
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Tests for module onadata.apps.api.tasks"""
2+
3+
from unittest.mock import patch
4+
5+
from onadata.apps.main.tests.test_base import TestBase
6+
from onadata.apps.api.tasks import (
7+
send_project_invitation_email_async,
8+
)
9+
from onadata.apps.logger.models import ProjectInvitation
10+
from onadata.libs.utils.user_auth import get_user_default_project
11+
from onadata.libs.utils.email import ProjectInvitationEmail
12+
13+
14+
class SendProjectInivtationEmailAsyncTestCase(TestBase):
15+
"""Tests for send_project_invitation_email_async"""
16+
17+
def setUp(self) -> None:
18+
super().setUp()
19+
20+
project = get_user_default_project(self.user)
21+
self.invitation = ProjectInvitation.objects.create(
22+
project=project,
23+
24+
role="manager",
25+
)
26+
27+
@patch.object(ProjectInvitationEmail, "send")
28+
def test_sends_email(self, mock_send):
29+
"""Test email is sent"""
30+
url = "https://example.com/register"
31+
send_project_invitation_email_async(self.invitation.id, url)
32+
mock_send.assert_called_once()

0 commit comments

Comments
 (0)