diff --git a/docs/projects.rst b/docs/projects.rst index af4fc22d27..677f76ae1e 100644 --- a/docs/projects.rst +++ b/docs/projects.rst @@ -638,7 +638,7 @@ Response } -The link embedded in the email will be of the format ``http://{url}?invitation_id={id}&invitation_token={token}`` +The link embedded in the email will be of the format ``http://{url}`` where: - ``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. @@ -652,9 +652,6 @@ adding the setting ``PROJECT_INVITATION_URL`` PROJECT_INVITATION_URL = 'https://example.com/register' -- ``id`` - The ``ProjectInvitation`` object primary key encoded to base 64 -- ``token`` - is a hash value that will be used to confirm validity of the link. - Update a project invitation --------------------------- @@ -751,28 +748,7 @@ Accept a project invitation --------------------------- Since a project invitation is sent to an unregistered user, acceptance of the invitation is handled -when creating a new user. - -The ``invitation_id`` and ``invitation_token`` query params are added to -the `create user `_ endpoint - -where: - -- ``id`` - is the value of the project ``invitation_id`` query parameter from the url embedded in the project invitation email -- ``token`` - is the value of the project ``invitation_token`` query parameter from the url embedded in the project invitation email - -.. raw:: html - -
POST /api/v1/profiles?invitation_id={id}&invitation_token={token}
- - -The validation of the ``id`` and ``token`` are dependent on one another and both should be provided for -successful validation. Failure of validation does not prevent the account creation. However, the new -user will not have the projects shared with them. - -If the validation for ``id`` and ``token`` is successful: - -- The invitation will be accepted including any other pending invitations whose emails match the invitation's email. -- If the invitation's email matches the new user's email, the new user's will immediately be marked as verified. +when `creating a new user `_. -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. \ No newline at end of file +All pending invitations whose email match the new user's email will be accepted and projects shared with the +user \ No newline at end of file diff --git a/onadata/apps/api/tasks.py b/onadata/apps/api/tasks.py index 7d785ac153..268cbe1931 100644 --- a/onadata/apps/api/tasks.py +++ b/onadata/apps/api/tasks.py @@ -6,7 +6,6 @@ import sys import logging from datetime import timedelta -from typing import Optional from celery.result import AsyncResult from django.conf import settings @@ -21,7 +20,6 @@ from onadata.libs.utils.model_tools import queryset_iterator from onadata.apps.logger.models import Instance, ProjectInvitation, XForm from onadata.libs.utils.email import ProjectInvitationEmail -from onadata.libs.utils.user_auth import accept_project_invitation from onadata.celeryapp import app User = get_user_model() @@ -147,24 +145,3 @@ def send_project_invitation_email_async( else: email = ProjectInvitationEmail(invitation, url) email.send() - - -@app.task() -def accept_project_invitation_async(user_id: str, invitation_id: Optional[str] = None): - """Accpet project invitation asynchronously""" - invitation = None - - if invitation_id: - try: - invitation = ProjectInvitation.objects.get(id=invitation_id) - except ProjectInvitation.DoesNotExist as err: - logging.exception(err) - - try: - user = User.objects.get(pk=user_id) - - except User.DoesNotExist as err: - logging.exception(err) - - else: - accept_project_invitation(user, invitation) diff --git a/onadata/apps/api/tests/test_tasks.py b/onadata/apps/api/tests/test_tasks.py index b8e2a783bd..d1a1a3e648 100644 --- a/onadata/apps/api/tests/test_tasks.py +++ b/onadata/apps/api/tests/test_tasks.py @@ -5,7 +5,6 @@ from onadata.apps.main.tests.test_base import TestBase from onadata.apps.api.tasks import ( send_project_invitation_email_async, - accept_project_invitation_async, ) from onadata.apps.logger.models import ProjectInvitation from onadata.libs.utils.user_auth import get_user_default_project @@ -31,28 +30,3 @@ def test_sends_email(self, mock_send): url = "https://example.com/register" send_project_invitation_email_async(self.invitation.id, url) mock_send.assert_called_once() - - -@patch("onadata.apps.api.tasks.accept_project_invitation") -class AcceptProjectInvitationTesCase(TestBase): - """Tests for accept_project_invitation_async""" - - def setUp(self): - super().setUp() - - project = get_user_default_project(self.user) - self.invitation = ProjectInvitation.objects.create( - project=project, - email="janedoe@example.com", - role="manager", - ) - - def test_accept_invitation(self, mock_accept_invitation): - """Test invitation is accepted""" - accept_project_invitation_async(self.user.id, self.invitation.id) - mock_accept_invitation.assert_called_once_with(self.user, self.invitation) - - def test_invitation_id_optional(self, mock_accept_invitation): - """invitation_id argument is optional""" - accept_project_invitation_async(self.user.id) - mock_accept_invitation.assert_called_once_with(self.user, None) diff --git a/onadata/apps/api/tests/viewsets/test_user_profile_viewset.py b/onadata/apps/api/tests/viewsets/test_user_profile_viewset.py index 0c71e7c7a2..8ee77a00df 100644 --- a/onadata/apps/api/tests/viewsets/test_user_profile_viewset.py +++ b/onadata/apps/api/tests/viewsets/test_user_profile_viewset.py @@ -7,7 +7,6 @@ import json import os from six.moves.urllib.parse import urlparse, parse_qs -from mock import Mock from django.contrib.auth import get_user_model from django.core.cache import cache @@ -32,7 +31,7 @@ from onadata.apps.main.models.user_profile import set_kpi_formbuilder_permissions from onadata.libs.authentication import DigestAuthentication from onadata.libs.serializers.user_profile_serializer import _get_first_last_names -from onadata.libs.utils.email import ProjectInvitationEmail +from onadata.libs.permissions import EditorRole User = get_user_model() @@ -294,19 +293,13 @@ def test_profile_create(self, mock_send_verification_email): @override_settings(CELERY_TASK_ALWAYS_EAGER=True) @override_settings(ENABLE_EMAIL_VERIFICATION=True) - @patch( - ( - "onadata.libs.serializers.user_profile_serializer." - "accept_project_invitation_async.delay" - ) - ) @patch( ( "onadata.libs.serializers.user_profile_serializer." "send_verification_email.delay" ) ) - def test_accept_invitaton(self, mock_send_email, mock_accept_invitation): + def test_accept_invitaton(self, mock_send_email): """An invitation is accepted successfuly""" self._project_create() invitation = ProjectInvitation.objects.create( @@ -318,84 +311,19 @@ def test_accept_invitaton(self, mock_send_email, mock_accept_invitation): data = _profile_data() del data["name"] data["email"] = invitation.email - - with patch.object( - ProjectInvitationEmail, "check_invitation", Mock(return_value=invitation) - ) as mock_check_invitation: - request = self.factory.post( - "/api/v1/profiles?invitation_id=id&invitation_token=token", - data=json.dumps(data), - content_type="application/json", - **self.extra, - ) - response = self.view(request) - self.assertEqual(response.status_code, 201) - user = User.objects.get(username="deno") - mock_check_invitation.assert_called_once_with("id", "token") - mock_accept_invitation.assert_called_with(user.id, invitation.id) - # user email matches invitation email so no need to send - # verification email - mock_send_email.assert_not_called() - self.assertTrue(user.profile.metadata["is_email_verified"]) - - # user registers using a different email from invitation email - data["email"] = "nickiminaj@example.com" - data["username"] = "nicki" - - with patch.object( - ProjectInvitationEmail, "check_invitation", Mock(return_value=invitation) - ): - request = self.factory.post( - "/api/v1/profiles?invitation_id=some_valid_id&invitation_token=some_token", - data=json.dumps(data), - content_type="application/json", - **self.extra, - ) - response = self.view(request) - self.assertEqual(response.status_code, 201) - user = User.objects.get(username=data["username"]) - mock_accept_invitation.assert_called_with(user.id, invitation.id) - # user email does not match invitation email so we send email verification - mock_send_email.assert_called_once() - self.assertFalse(user.profile.metadata.get("is_email_verified", False)) - - # invitation_id and invitation_token missing - data["email"] = "jack@example.com" - data["username"] = "jack" - - with patch.object( - ProjectInvitationEmail, "check_invitation", Mock(return_value=invitation) - ): - request = self.factory.post( - "/api/v1/profiles", - data=json.dumps(data), - content_type="application/json", - **self.extra, - ) - response = self.view(request) - self.assertEqual(response.status_code, 201) - user = User.objects.get(username=data["username"]) - mock_accept_invitation.assert_called_with(user.id, None) - self.assertFalse(user.profile.metadata.get("is_email_verified", False)) - - # invalid invitation - data["email"] = "jude@example.com" - data["username"] = "jude" - - with patch.object( - ProjectInvitationEmail, "check_invitation", Mock(return_value=None) - ): - request = self.factory.post( - "/api/v1/profiles?invitation_id=some_valid_id&invitation_token=some_token", - data=json.dumps(data), - content_type="application/json", - **self.extra, - ) - response = self.view(request) - self.assertEqual(response.status_code, 201) - user = User.objects.get(username=data["username"]) - mock_accept_invitation.assert_called_with(user.id, None) - self.assertFalse(user.profile.metadata.get("is_email_verified", False)) + request = self.factory.post( + "/api/v1/profiles", + data=json.dumps(data), + content_type="application/json", + **self.extra, + ) + response = self.view(request) + self.assertEqual(response.status_code, 201) + user = User.objects.get(username="deno") + mock_send_email.assert_called_once() + invitation.refresh_from_db() + self.assertEqual(invitation.status, ProjectInvitation.Status.ACCEPTED) + self.assertTrue(EditorRole.user_has_role(user, self.project)) def _create_user_using_profiles_endpoint(self, data): request = self.factory.post( diff --git a/onadata/apps/api/viewsets/user_profile_viewset.py b/onadata/apps/api/viewsets/user_profile_viewset.py index 2b7d27c60b..aa12ae157f 100644 --- a/onadata/apps/api/viewsets/user_profile_viewset.py +++ b/onadata/apps/api/viewsets/user_profile_viewset.py @@ -206,18 +206,6 @@ def get_object(self, queryset=None): return obj - def get_serializer(self, *args, **kwargs): - """Override get_serializer""" - if (invitation_id := self.request.query_params.get("invitation_id")) and ( - invitation_token := self.request.query_params.get("invitation_token") - ): - draft_request_data = self.request.data.copy() - draft_request_data["invitation_id"] = invitation_id - draft_request_data["invitation_token"] = invitation_token - kwargs["data"] = draft_request_data - - return super().get_serializer(*args, **kwargs) - def update(self, request, *args, **kwargs): """Update user in cache and db""" username = kwargs.get("user") diff --git a/onadata/apps/main/signals.py b/onadata/apps/main/signals.py index 824d03b7d6..e9407c7962 100644 --- a/onadata/apps/main/signals.py +++ b/onadata/apps/main/signals.py @@ -4,10 +4,13 @@ """ from django.conf import settings from django.contrib.auth import get_user_model +from django.db.models.signals import post_save +from django.dispatch import receiver from django.template.loader import render_to_string +from django.utils import timezone from onadata.libs.utils.email import send_generic_email - +from onadata.apps.logger.models import ProjectInvitation User = get_user_model() @@ -62,3 +65,26 @@ def send_activation_email(sender, instance=None, **kwargs): send_generic_email( instance.email, email, f"{deployment_name} account activated" ) + + +@receiver(post_save, sender=User, dispatch_uid="accept_project_invitation") +def accept_project_invitation(sender, instance=None, created=False, **kwargs): + """Accept project invitations that match user email""" + if created: + invitation_qs = ProjectInvitation.objects.filter( + email=instance.email, + status=ProjectInvitation.Status.PENDING, + ) + now = timezone.now() + # ShareProject needs to be imported inline because otherwise we get + # django.core.exceptions.AppRegistryNotReady: Apps aren't loaded yet. + # pylint: disable=import-outside-toplevel + from onadata.libs.models.share_project import ShareProject + + for invitation in invitation_qs: + ShareProject( + invitation.project, + instance.username, + invitation.role, + ).save() + invitation.accept(accepted_at=now, accepted_by=instance) diff --git a/onadata/apps/main/tests/test_signals.py b/onadata/apps/main/tests/test_signals.py new file mode 100644 index 0000000000..0f09455c97 --- /dev/null +++ b/onadata/apps/main/tests/test_signals.py @@ -0,0 +1,75 @@ +import pytz + +from datetime import datetime +from unittest.mock import Mock, patch + +from django.contrib.auth import get_user_model + +from onadata.apps.main.tests.test_base import TestBase +from onadata.apps.logger.models import ProjectInvitation, Project +from onadata.libs.utils.user_auth import get_user_default_project +from onadata.libs.permissions import EditorRole, ManagerRole + +User = get_user_model() + + +class AcceptProjectInvitationTestCase(TestBase): + """Tests for accept_project_inviation""" + + def setUp(self): + super().setUp() + self.project = get_user_default_project(self.user) + self.invitation = ProjectInvitation.objects.create( + email="mike@example.com", + project=self.project, + role="editor", + ) + self.mocked_now = datetime(2023, 6, 21, 14, 29, 0, tzinfo=pytz.utc) + + def test_accept_invitation(self): + """Accept invitation works""" + john_invitation = ProjectInvitation.objects.create( + email="johndoe@example.com", + project=self.project, + role="manager", + ) + project = Project.objects.create( + name="Project 2", + created_by=self.user, + organization=self.user, + ) + mike_invitation = ProjectInvitation.objects.create( + email="mike@example.com", + project=project, + role="manager", + ) + + with patch("django.utils.timezone.now", Mock(return_value=self.mocked_now)): + mike = User.objects.create(username="mike", email="mike@example.com") + self.invitation.refresh_from_db() + self.assertEqual(self.invitation.status, ProjectInvitation.Status.ACCEPTED) + self.assertEqual(self.invitation.accepted_at, self.mocked_now) + self.assertEqual(self.invitation.accepted_by, mike) + self.assertTrue(EditorRole.user_has_role(mike, self.project)) + # other invitations are not touched + john_invitation.refresh_from_db() + self.assertEqual(john_invitation.status, ProjectInvitation.Status.PENDING) + # other projects are shared + mike_invitation.refresh_from_db() + self.assertEqual(mike_invitation.status, ProjectInvitation.Status.ACCEPTED) + self.assertEqual(mike_invitation.accepted_at, self.mocked_now) + self.assertEqual(mike_invitation.accepted_by, mike) + self.assertTrue(ManagerRole.user_has_role(mike, project)) + + def test_only_pending_accepted(self): + """Only pending invitations are accepted""" + self.invitation.status = ProjectInvitation.Status.REVOKED + self.invitation.save() + + with patch("django.utils.timezone.now", Mock(return_value=self.mocked_now)): + mike = User.objects.create(username="mike", email="mike@example.com") + self.invitation.refresh_from_db() + self.assertEqual(self.invitation.status, ProjectInvitation.Status.REVOKED) + self.assertIsNone(self.invitation.accepted_at) + self.assertIsNone(self.invitation.accepted_by) + self.assertFalse(EditorRole.user_has_role(mike, self.project)) diff --git a/onadata/libs/serializers/user_profile_serializer.py b/onadata/libs/serializers/user_profile_serializer.py index dca0217256..6290927417 100644 --- a/onadata/libs/serializers/user_profile_serializer.py +++ b/onadata/libs/serializers/user_profile_serializer.py @@ -24,7 +24,6 @@ from onadata.apps.api.models.temp_token import TempToken from onadata.apps.api.tasks import ( send_verification_email, - accept_project_invitation_async, ) from onadata.apps.main.forms import RegistrationFormUserProfile from onadata.apps.main.models import UserProfile @@ -36,7 +35,6 @@ from onadata.libs.utils.email import ( get_verification_email_data, get_verification_url, - ProjectInvitationEmail, ) RESERVED_NAMES = RegistrationFormUserProfile.RESERVED_USERNAMES @@ -149,8 +147,6 @@ class UserProfileSerializer(serializers.HyperlinkedModelSerializer): # pylint: disable=invalid-name id = serializers.ReadOnlyField(source="user.id") joined_on = serializers.ReadOnlyField(source="user.date_joined") - invitation_id = serializers.CharField(required=False, write_only=True) - invitation_token = serializers.CharField(required=False, write_only=True) # pylint: disable=too-few-public-methods,missing-class-docstring class Meta: @@ -175,8 +171,6 @@ class Meta: "metadata", "joined_on", "name", - "invitation_id", - "invitation_token", ) owner_only_fields = ("metadata",) @@ -273,8 +267,6 @@ def update(self, instance, validated_data): ) def create(self, validated_data): """Creates a user registration profile and account.""" - encoded_invitation_id = validated_data.pop("invitation_id", None) - invitation_token = validated_data.pop("invitation_token", None) params = validated_data request = self.context.get("request") metadata = {} @@ -320,27 +312,10 @@ def create(self, validated_data): metadata=metadata, ) profile.save() - invitation = None - - if encoded_invitation_id and invitation_token: - invitation = ProjectInvitationEmail.check_invitation( - encoded_invitation_id, invitation_token - ) if getattr(settings, "ENABLE_EMAIL_VERIFICATION", False): - if invitation and invitation.email == new_user.email: - # Mark users email as verified. No need to for them to verify - # if they registered using the email that the invite was sent to - profile.metadata.update({"is_email_verified": True}) - profile.save() - - else: - redirect_url = params.get("redirect_url") - _send_verification_email(redirect_url, new_user, request) - - accept_project_invitation_async.delay( - new_user.id, invitation.id if invitation else None - ) + redirect_url = params.get("redirect_url") + _send_verification_email(redirect_url, new_user, request) return profile diff --git a/onadata/libs/tests/utils/test_email.py b/onadata/libs/tests/utils/test_email.py index a5c5585269..55ff86ffa6 100644 --- a/onadata/libs/tests/utils/test_email.py +++ b/onadata/libs/tests/utils/test_email.py @@ -1,11 +1,7 @@ -import six -from datetime import datetime from six.moves.urllib.parse import urlencode from mock import patch -from django.utils.http import urlsafe_base64_encode from django.test import RequestFactory from django.test.utils import override_settings -from django.utils.encoding import force_bytes from onadata.apps.main.tests.test_base import TestBase from onadata.libs.utils.email import ( get_verification_email_data, @@ -149,34 +145,17 @@ def setUp(self) -> None: self.invitation, "https://example.com/register" ) - def _mock_invitation_make_token(self): - return "tokenmoto" - - @patch.object(ProjectInvitationEmail, "_make_token", _mock_invitation_make_token) - @patch("onadata.libs.utils.email.urlsafe_base64_encode") - def test_make_url(self, mock_base64_encode): - """The invitation link created is correct""" - mock_base64_encode.return_value = "idbase64" - link = ( - "https://example.com/register?" - "invitation_id=idbase64&invitation_token=tokenmoto" - ) - self.assertEqual(self.email.make_url(), link) - @override_settings(DEPLOYMENT_NAME="Misfit") - @patch.object(ProjectInvitationEmail, "_make_token", _mock_invitation_make_token) - @patch("onadata.libs.utils.email.urlsafe_base64_encode") @patch("onadata.libs.utils.email.send_generic_email") - def test_send(self, mock_send, mock_base64_encode): + def test_send(self, mock_send): """Email is sent successfully""" - mock_base64_encode.return_value = "idbase64" self.email.send() email_data = { "subject": "Invitation to Join a Project on Misfit", "message_txt": "\nHello,\n\nYou have been added to Test Invitation by" " a project admin allowing you to begin data collection.\n\nTo begin" " using Misfit, please create an account first by clicking the link below:" - "\nhttps://example.com/register?invitation_id=idbase64&invitation_token=tokenmoto" + "\nhttps://example.com/register" "\n\nThanks,\nThe Team at Misfit\n", } mock_send.assert_called_with( @@ -184,44 +163,15 @@ def test_send(self, mock_send, mock_base64_encode): **email_data, ) - def test_check_invitation(self): - """Check invitation works correctly""" - # valid invitation_id and token passes - token = self.email._make_token() - invitation_id = urlsafe_base64_encode(force_bytes(self.invitation.id)) - check = ProjectInvitationEmail.check_invitation(invitation_id, token) - self.assertEqual(self.invitation, check) - # invalid id does not pass - check = ProjectInvitationEmail.check_invitation("foo", token) - self.assertIsNone(check) - # invalid token does not pass - check = ProjectInvitationEmail.check_invitation(invitation_id, "sometoken") - self.assertIsNone(check) - - def test_make_token(self): - """Ensure the hash algorithm is correct""" - timestamp = datetime.now().timestamp - expected_hash = ( - six.text_type(self.invitation.pk) - + six.text_type(timestamp) # noqa W503 - + six.text_type(self.invitation.status) # noqa W503 - ) - self.assertEqual( - self.email._make_hash_value(self.invitation, timestamp), expected_hash - ) - @override_settings(DEPLOYMENT_NAME="Misfit") - @patch.object(ProjectInvitationEmail, "_make_token", _mock_invitation_make_token) - @patch("onadata.libs.utils.email.urlsafe_base64_encode") - def test_get_template_data(self, mock_base64_encode): + def test_get_template_data(self): """Context data for the email templates is correct""" - mock_base64_encode.return_value = "idbase64" expected_data = { "subject": {"deployment_name": "Misfit"}, "body": { "deployment_name": "Misfit", "project_name": "Test Invitation", - "invitation_url": "https://example.com/register?invitation_id=idbase64&invitation_token=tokenmoto", + "invitation_url": "https://example.com/register", "organization": "Test User", }, } diff --git a/onadata/libs/tests/utils/test_user_auth.py b/onadata/libs/tests/utils/test_user_auth.py deleted file mode 100644 index 26900a6b50..0000000000 --- a/onadata/libs/tests/utils/test_user_auth.py +++ /dev/null @@ -1,120 +0,0 @@ -import pytz - -from datetime import datetime -from unittest.mock import Mock, patch -from onadata.apps.main.tests.test_base import TestBase -from onadata.libs.utils.user_auth import accept_project_invitation -from onadata.apps.logger.models import ProjectInvitation, Project -from onadata.libs.utils.user_auth import get_user_default_project -from onadata.libs.permissions import EditorRole, ManagerRole - - -class AcceptProjectInvitationTestCase(TestBase): - """Tests for accept_project_inviation""" - - def setUp(self): - super().setUp() - self.bob = self._create_user("mike", "1234", True) - self.project = get_user_default_project(self.bob) - self.email = "janedoe@example.com" - self.invitation = ProjectInvitation.objects.create( - email=self.email, - project=self.project, - role="editor", - ) - self.user.email = self.email - self.user.save() - self.mocked_now = datetime(2023, 6, 21, 14, 29, 0, tzinfo=pytz.utc) - - def test_accept_invitation(self): - """Accept invitation works""" - self.assertEqual(self.user.email, self.email) - - john_invitation = ProjectInvitation.objects.create( - email="johndoe@example.com", - project=self.project, - role="manager", - ) - project = Project.objects.create( - name="Bob project 2", - created_by=self.bob, - organization=self.bob, - ) - invitation = ProjectInvitation.objects.create( - email=self.email, - project=project, - role="manager", - ) - - with patch("django.utils.timezone.now", Mock(return_value=self.mocked_now)): - accept_project_invitation(self.user, self.invitation) - self.invitation.refresh_from_db() - self.assertEqual(self.invitation.status, ProjectInvitation.Status.ACCEPTED) - self.assertEqual(self.invitation.accepted_at, self.mocked_now) - self.assertEqual(self.invitation.accepted_by, self.user) - self.assertTrue(EditorRole.user_has_role(self.user, self.project)) - # other invitations are not touched - john_invitation.refresh_from_db() - self.assertEqual(john_invitation.status, ProjectInvitation.Status.PENDING) - # other projects are shared - invitation.refresh_from_db() - self.assertEqual(invitation.status, ProjectInvitation.Status.ACCEPTED) - self.assertEqual(invitation.accepted_at, self.mocked_now) - self.assertEqual(invitation.accepted_by, self.user) - self.assertTrue(ManagerRole.user_has_role(self.user, project)) - - def test_different_user_email(self): - """Invitations accepted if user email is different from invitation email""" - email = "nickiminaj@example.com" - self.invitation.email = email - self.invitation.save() - - self.assertNotEqual(self.user.email, self.invitation.email) - - project = Project.objects.create( - name="Bob project 2", - created_by=self.bob, - organization=self.bob, - ) - invitation = ProjectInvitation.objects.create( - email=email, - project=project, - role="manager", - ) - - with patch("django.utils.timezone.now", Mock(return_value=self.mocked_now)): - accept_project_invitation(self.user, self.invitation) - self.invitation.refresh_from_db() - self.assertEqual(self.invitation.status, ProjectInvitation.Status.ACCEPTED) - self.assertEqual(self.invitation.accepted_at, self.mocked_now) - self.assertEqual(self.invitation.accepted_by, self.user) - self.assertTrue(EditorRole.user_has_role(self.user, self.project)) - # other projects are shared - invitation.refresh_from_db() - self.assertEqual(invitation.status, ProjectInvitation.Status.ACCEPTED) - self.assertEqual(invitation.accepted_at, self.mocked_now) - self.assertEqual(invitation.accepted_by, self.user) - self.assertTrue(ManagerRole.user_has_role(self.user, project)) - - def test_only_pending_accepted(self): - """Only pending invitations are accepted""" - self.invitation.status = ProjectInvitation.Status.REVOKED - self.invitation.save() - - with patch("django.utils.timezone.now", Mock(return_value=self.mocked_now)): - accept_project_invitation(self.user, self.invitation) - self.invitation.refresh_from_db() - self.assertEqual(self.invitation.status, ProjectInvitation.Status.REVOKED) - self.assertIsNone(self.invitation.accepted_at) - self.assertIsNone(self.invitation.accepted_by) - self.assertFalse(EditorRole.user_has_role(self.user, self.project)) - - def test_invitation_optional(self): - """Invitation is optional - - If invitation is not provided, invitations matching the user email - are accepted - """ - accept_project_invitation(self.user) - self.invitation.refresh_from_db() - self.assertEqual(self.invitation.status, ProjectInvitation.Status.ACCEPTED) diff --git a/onadata/libs/utils/email.py b/onadata/libs/utils/email.py index 079411d244..527e85c246 100644 --- a/onadata/libs/utils/email.py +++ b/onadata/libs/utils/email.py @@ -2,20 +2,10 @@ """ email utility functions. """ -from typing import Optional -import six from django.conf import settings from django.core.mail import EmailMultiAlternatives -from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.http import HttpRequest -from django.utils.http import ( - base36_to_int, - urlsafe_base64_encode, - urlsafe_base64_decode, -) -from django.utils.crypto import constant_time_compare from django.template.loader import render_to_string -from django.utils.encoding import force_bytes from six.moves.urllib.parse import urlencode from rest_framework.reverse import reverse from onadata.apps.logger.models import ProjectInvitation @@ -89,72 +79,6 @@ def send_generic_email(email, message_txt, subject): email_message.send() -class ProjectInvitationTokenGenerator(PasswordResetTokenGenerator): - """Strategy object for generating and checking tokens for project invitation URL""" - - def check_token(self, invitation, token): # pylint: disable=arguments-renamed - """ - Check that a project invitation token is correct for a given user. - """ - if not (invitation and token): - return False - # Parse the token - try: - ts_b36, _ = token.split("-") - except ValueError: - return False - - try: - ts = base36_to_int(ts_b36) # pylint: disable=invalid-name - except ValueError: - return False - - # Check that the timestamp/uid has not been tampered with - if not constant_time_compare( - self._make_token_with_timestamp( # pylint: disable=no-value-for-parameter - invitation, ts - ), - token, - ): - # RemovedInDjango40Warning: when the deprecation ends, replace - # with: - # return False - if not constant_time_compare( - # pylint: disable=no-value-for-parameter,unexpected-keyword-arg - self._make_token_with_timestamp( - invitation, - ts, - legacy=True, - ), - token, - ): - return False - - return True - - def _make_hash_value( # pylint: disable=arguments-renamed - self, - invitation, - timestamp, - ): - """Make a hash value for the invitation token - - The hash is made up of: - - 1. primary key of the invitation - will uniquely identify the - hash as belonging to a particular inivtation - 2. timestamp - the current timestamp - 3. invitation status - will invaliddate the link when the status - changes. If an invitation with a status of pending changes to accepted, - the link will be invalidated and cannot be re-used - """ - return ( - six.text_type(invitation.pk) - + six.text_type(timestamp) # noqa W503 - + six.text_type(invitation.status) # noqa W503 - ) - - def get_project_invitation_url(request: HttpRequest): """Get project invitation url""" url: str = getattr(settings, "PROJECT_INVITATION_URL", "") @@ -165,7 +89,7 @@ def get_project_invitation_url(request: HttpRequest): return url -class ProjectInvitationEmail(ProjectInvitationTokenGenerator): +class ProjectInvitationEmail: """ A class to send a project invitation email """ @@ -176,39 +100,6 @@ def __init__(self, invitation: ProjectInvitation, url: str) -> None: self.invitation = invitation self.url = url - def _make_token(self) -> str: - return super().make_token(self.invitation) - - @staticmethod - def check_invitation(encoded_id: str, token: str) -> Optional[ProjectInvitation]: - """Check if an invitation is valid""" - try: - invitation_id = int(urlsafe_base64_decode(encoded_id)) - - except ValueError: - return None - - try: - invitation = ProjectInvitation.objects.get(pk=invitation_id) - - except ProjectInvitation.DoesNotExist: - return None - - if ProjectInvitationTokenGenerator().check_token(invitation, token): - return invitation - - return None - - def make_url(self) -> str: - """Returns the project invitation URL to be embedded in the email""" - query_params: dict[str, str] = { - "invitation_id": urlsafe_base64_encode(force_bytes(self.invitation.id)), - "invitation_token": self._make_token(), - } - query_params_string = urlencode(query_params) - - return f"{self.url}?{query_params_string}" - def get_template_data(self) -> dict[str, str]: """Get context data for the templates""" deployment_name = getattr(settings, "DEPLOYMENT_NAME", "Ona") @@ -218,7 +109,7 @@ def get_template_data(self) -> dict[str, str]: "body": { "deployment_name": deployment_name, "project_name": self.invitation.project.name, - "invitation_url": self.make_url(), + "invitation_url": self.url, "organization": organization, }, } @@ -235,7 +126,7 @@ def get_email_data(self) -> dict[str, str]: "message_txt": render_to_string( message_path, template_data["body"], - ).replace('&', '&'), + ), } return email_data diff --git a/onadata/libs/utils/user_auth.py b/onadata/libs/utils/user_auth.py index fb648c0208..5e3d9c75cc 100644 --- a/onadata/libs/utils/user_auth.py +++ b/onadata/libs/utils/user_auth.py @@ -5,14 +5,12 @@ import base64 import re from functools import wraps -from typing import Optional from django.apps import apps from django.contrib.auth import authenticate, get_user_model from django.contrib.sites.models import Site from django.http import HttpResponse from django.shortcuts import get_object_or_404 -from django.utils import timezone from guardian.shortcuts import assign_perm, get_perms_for_model from rest_framework.authtoken.models import Token @@ -23,8 +21,6 @@ from onadata.apps.logger.models.project import Project from onadata.apps.logger.models.xform import XForm from onadata.libs.utils.viewer_tools import get_form -from onadata.apps.logger.models import ProjectInvitation -from onadata.libs.models.share_project import ShareProject # pylint: disable=invalid-name User = get_user_model() @@ -267,35 +263,3 @@ def invalidate_and_regen_tokens(user): temp_token = TempToken.objects.create(user=user).key return {"access_token": access_token, "temp_token": temp_token} - - -def accept_project_invitation( - user: User, project_invitation: Optional[ProjectInvitation] = None -) -> None: - """Accept a project inivitation and share project with user - - Accepts all invitations whose email matches the user's email and - the invitations's email - """ - - invitation_qs = ProjectInvitation.objects.filter( - email=user.email, - status=ProjectInvitation.Status.PENDING, - ) - - if project_invitation: - invitation_email_qs = ProjectInvitation.objects.filter( - email=project_invitation.email, - status=ProjectInvitation.Status.PENDING, - ) - invitation_qs = invitation_qs.union(invitation_email_qs) - - now = timezone.now() - - for invitation in invitation_qs: - ShareProject( - invitation.project, - user.username, - invitation.role, - ).save() - invitation.accept(accepted_at=now, accepted_by=user)