diff --git a/backend/projectify/user/services/user_invite.py b/backend/projectify/user/services/user_invite.py index 15c332dd8..66af2a6d3 100644 --- a/backend/projectify/user/services/user_invite.py +++ b/backend/projectify/user/services/user_invite.py @@ -19,6 +19,7 @@ from typing import Optional from django.db import transaction +from django.utils.timezone import now from projectify.user.models import User, UserInvite from projectify.user.selectors.user import user_find_by_email @@ -52,14 +53,18 @@ def user_invite_redeem(*, user_invite: UserInvite, user: User) -> None: # Add user to workspaces for any outstanding invites qs = WorkspaceUserInvite.objects.filter( + # This plausibly keeps us from redeeming the same invite twice user_invite__user=user, + redeemed=False, ) for invite in qs: workspace = invite.workspace workspace_add_user( workspace=workspace, user=user, role=WorkspaceUserRoles.OBSERVER ) - invite.redeem() + invite.redeemed = True + invite.redeemed_when = now() + invite.save() @transaction.atomic diff --git a/backend/projectify/workspace/migrations/0057_workspaceuserinvite_redeemed_when.py b/backend/projectify/workspace/migrations/0057_workspaceuserinvite_redeemed_when.py new file mode 100644 index 000000000..57761a4ce --- /dev/null +++ b/backend/projectify/workspace/migrations/0057_workspaceuserinvite_redeemed_when.py @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# Copyright (C) 2024 JWP Consulting GK +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""Annotate ws user invite with redeemd_when dt field.""" +# Generated by Django 4.2.7 on 2024-02-22 05:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + """Migration.""" + + dependencies = [ + ("workspace", "0056_alter_label_options"), + ] + + operations = [ + migrations.AddField( + model_name="workspaceuserinvite", + name="redeemed_when", + field=models.DateTimeField( + blank=True, + default=None, + editable=False, + help_text="When has this invite been redeemed?", + null=True, + ), + ), + ] diff --git a/backend/projectify/workspace/models/workspace_user_invite.py b/backend/projectify/workspace/models/workspace_user_invite.py index 7dfd66789..4be8c9124 100644 --- a/backend/projectify/workspace/models/workspace_user_invite.py +++ b/backend/projectify/workspace/models/workspace_user_invite.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # -# Copyright (C) 2023 JWP Consulting GK +# Copyright (C) 2023-2024 JWP Consulting GK # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published @@ -15,73 +15,40 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Contains workspace user invite qs / manager / model.""" -from typing import ( - TYPE_CHECKING, - ClassVar, - Self, - cast, -) -from django.db import ( - models, -) +from django.db import models from django.utils.translation import gettext_lazy as _ from projectify.lib.models import BaseModel +from projectify.user.models import UserInvite -from .types import ( - Pks, -) - -if TYPE_CHECKING: - from projectify.user.models import UserInvite # noqa: F401 - from projectify.workspace.models import Workspace # noqa: F401 - - -class WorkspaceUserInviteQuerySet(models.QuerySet["WorkspaceUserInvite"]): - """QuerySet for WorkspaceUserInvite.""" - - def filter_by_workspace_pks(self, workspace_pks: Pks) -> Self: - """Filter by workspace pks.""" - return self.filter(workspace__pk__in=workspace_pks) - - def filter_by_redeemed(self, redeemed: bool = True) -> Self: - """Filter by redeemed workspace user invites.""" - return self.filter(redeemed=redeemed) +from ..models import Workspace class WorkspaceUserInvite(BaseModel): """UserInvites belonging to this workspace.""" - user_invite = models.ForeignKey["UserInvite"]( + user_invite = models.ForeignKey[UserInvite]( "user.UserInvite", on_delete=models.CASCADE, ) - workspace = models.ForeignKey["Workspace"]( + workspace = models.ForeignKey[Workspace]( "Workspace", on_delete=models.CASCADE, ) - # TODO make this a datetimefield with default null + # TODO use redeemed_when only redeemed = models.BooleanField( default=False, - # TODO this should then say "When has this invite been redeemed?" help_text=_("Has this invite been redeemed?"), ) - - objects: ClassVar[WorkspaceUserInviteQuerySet] = cast( # type: ignore[assignment] - WorkspaceUserInviteQuerySet, WorkspaceUserInviteQuerySet.as_manager() + redeemed_when = models.DateTimeField( + blank=True, + null=True, + editable=False, + default=None, + help_text=_("When has this invite been redeemed?"), ) - def redeem(self) -> None: - """ - Redeem invite. - - Save. - """ - assert not self.redeemed - self.redeemed = True - self.save() - class Meta: """Meta.""" diff --git a/backend/projectify/workspace/services/workspace_user_invite.py b/backend/projectify/workspace/services/workspace_user_invite.py index 35558a923..4af0fb412 100644 --- a/backend/projectify/workspace/services/workspace_user_invite.py +++ b/backend/projectify/workspace/services/workspace_user_invite.py @@ -33,6 +33,7 @@ from projectify.premail.email import EmailAddress from projectify.user.models import User, UserInvite from projectify.user.services.user_invite import user_invite_create +from projectify.workspace.services.signals import send_workspace_change_signal from ..emails import WorkspaceUserInviteEmail from ..exceptions import UserAlreadyAdded, UserAlreadyInvited @@ -162,6 +163,7 @@ def workspace_user_invite_create( ) email_to_send.send() + send_workspace_change_signal(workspace) return workspace_user_invite @@ -182,3 +184,4 @@ def workspace_user_invite_delete( ) case WorkspaceUserInvite() as workspace_user_invite: workspace_user_invite.delete() + send_workspace_change_signal(workspace) diff --git a/backend/projectify/workspace/test/models/test_workspace_user_invite.py b/backend/projectify/workspace/test/models/test_workspace_user_invite.py index 6fae0c543..c5f7ad794 100644 --- a/backend/projectify/workspace/test/models/test_workspace_user_invite.py +++ b/backend/projectify/workspace/test/models/test_workspace_user_invite.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # -# Copyright (C) 2023 JWP Consulting GK +# Copyright (C) 2023-2024 JWP Consulting GK # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published @@ -17,62 +17,15 @@ """Test WorkspaceUserInvite model.""" import pytest -from ... import ( - models, +from projectify.workspace.models.workspace_user_invite import ( + WorkspaceUserInvite, ) -@pytest.mark.django_db -class TestWorkspaceUserInviteQuerySet: - """Test WorkspaceUserInviteQuerySet.""" - - def test_filter_by_workspace_pks( - self, - workspace: models.Workspace, - workspace_user_invite: models.WorkspaceUserInvite, - ) -> None: - """Test filter_by_workspace_pks.""" - qs = models.WorkspaceUserInvite.objects.filter_by_workspace_pks( - [workspace.pk], - ) - assert list(qs) == [workspace_user_invite] - - def test_filter_by_redeemed( - self, - workspace: models.Workspace, - workspace_user_invite: models.WorkspaceUserInvite, - ) -> None: - """Test filter_by_redeemed.""" - qs = models.WorkspaceUserInvite.objects.filter_by_redeemed(False) - assert qs.count() == 1 - workspace_user_invite.redeem() - assert qs.count() == 0 - qs = models.WorkspaceUserInvite.objects.filter_by_redeemed(True) - assert qs.count() == 1 - - @pytest.mark.django_db class TestWorkspaceUserInvite: """Test workspace user invite.""" - def test_factory( - self, workspace_user_invite: models.WorkspaceUserInvite - ) -> None: + def test_factory(self, workspace_user_invite: WorkspaceUserInvite) -> None: """Test factory.""" assert workspace_user_invite - - def test_redeem( - self, workspace_user_invite: models.WorkspaceUserInvite - ) -> None: - """Test redeeming.""" - workspace_user_invite.redeem() - workspace_user_invite.refresh_from_db() - assert workspace_user_invite.redeemed - - def test_redeeming_twice( - self, workspace_user_invite: models.WorkspaceUserInvite - ) -> None: - """Test redeeming twice.""" - workspace_user_invite.redeem() - with pytest.raises(AssertionError): - workspace_user_invite.redeem() diff --git a/backend/projectify/workspace/test/test_consumers.py b/backend/projectify/workspace/test/test_consumers.py index 21e646519..1fa525d3b 100644 --- a/backend/projectify/workspace/test/test_consumers.py +++ b/backend/projectify/workspace/test/test_consumers.py @@ -88,7 +88,10 @@ workspace_user_delete, workspace_user_update, ) -from ..services.workspace_user_invite import workspace_user_invite_create +from ..services.workspace_user_invite import ( + workspace_user_invite_create, + workspace_user_invite_delete, +) logger = logging.getLogger(__name__) @@ -326,6 +329,23 @@ async def test_workspace_user_life_cycle( who=user, ) await expect_message(workspace_communicator, workspace) + + # Now we invite someone without an account: + await database_sync_to_async(workspace_user_invite_create)( + workspace=workspace, + email_or_user="doesnotexist@example.com", + who=user, + ) + await expect_message(workspace_communicator, workspace) + + # And we remove their invitation + await database_sync_to_async(workspace_user_invite_delete)( + workspace=workspace, + email="doesnotexist@example.com", + who=user, + ) + await expect_message(workspace_communicator, workspace) + # With only one remaining user, we call workspace_delete instead await database_sync_to_async(workspace_delete)( workspace=workspace, diff --git a/backend/projectify/workspace/test/views/test_workspace.py b/backend/projectify/workspace/test/views/test_workspace.py index 9a6b58e81..467e1957f 100644 --- a/backend/projectify/workspace/test/views/test_workspace.py +++ b/backend/projectify/workspace/test/views/test_workspace.py @@ -57,7 +57,7 @@ class TestWorkspaceCreate: @pytest.fixture def resource_url(self) -> str: """Return URL to this view.""" - return reverse("workspace:workspace-create") + return reverse("workspace:workspaces:create") def test_create( self, @@ -89,13 +89,13 @@ def test_create( # Read @pytest.mark.django_db -class TestWorkspaceList: +class TestUserWorkspaces: """Test Workspace list.""" @pytest.fixture def resource_url(self) -> str: """Return URL to this view.""" - return reverse("workspace:workspace-list") + return reverse("workspace:workspaces:user-workspaces") def test_authenticated( self, @@ -305,7 +305,7 @@ class TestWorkspacePictureUploadView: def resource_url(self, workspace: Workspace) -> str: """Return URL to this view.""" return reverse( - "workspace:workspace-picture-upload", args=(workspace.uuid,) + "workspace:workspaces:upload-picture", args=(workspace.uuid,) ) @pytest.fixture @@ -361,33 +361,55 @@ def test_upload_then_delete( @pytest.mark.django_db class TestInviteUserToWorkspace: - """Test InviteUserToWorkspace.""" + """Test two views, InviteUserToWorkspace and UninviteUserFromWorkspace.""" @pytest.fixture def resource_url(self, workspace_user: WorkspaceUser) -> str: """Return URL to this view.""" return reverse( - "workspace:workspace-invite-user", + "workspace:workspaces:invite-workspace-user", # Using the workspace_user fixture, we create a ws user and ws in # one go! Mighty clever I dare say >:) args=(workspace_user.workspace.uuid,), ) + @pytest.fixture + def uninvite_url(self, workspace_user: WorkspaceUser) -> str: + """Return URL to this view.""" + return reverse( + "workspace:workspaces:uninvite-workspace-user", + args=(workspace_user.workspace.uuid,), + ) + def test_new_user( self, resource_url: str, rest_user_client: APIClient, workspace: Workspace, + uninvite_url: str, ) -> None: """Test with a new, unregistered user.""" assert workspace.workspaceuserinvite_set.count() == 0 response = rest_user_client.post( - resource_url, - {"email": "taro@yamamoto.jp"}, + resource_url, {"email": "taro@yamamoto.jp"} ) assert response.status_code == 201, response.data assert workspace.workspaceuserinvite_set.count() == 1 + # Then uninvite them + response = rest_user_client.post( + uninvite_url, {"email": "taro@yamamoto.jp"} + ) + assert response.status_code == 204, response.data + assert workspace.workspaceuserinvite_set.count() == 0 + + # Can't uninvite twice + response = rest_user_client.post( + uninvite_url, {"email": "taro@yamamoto.jp"} + ) + assert response.status_code == 400, response.data + assert workspace.workspaceuserinvite_set.count() == 0 + def test_existing_user( self, resource_url: str, @@ -398,8 +420,7 @@ def test_existing_user( """Test by inviting an existing user.""" assert workspace.workspaceuserinvite_set.count() == 0 response = rest_user_client.post( - resource_url, - {"email": other_user.email}, + resource_url, {"email": other_user.email} ) assert response.status_code == 201, response.data assert workspace.workspaceuserinvite_set.count() == 0 @@ -412,8 +433,7 @@ def test_existing_workspace_user( ) -> None: """Test inviting an existing workspace user.""" response = rest_user_client.post( - resource_url, - {"email": workspace_user.user.email}, + resource_url, {"email": workspace_user.user.email} ) assert response.status_code == 400, response.data assert "already been added" in response.data["email"], response.data @@ -423,13 +443,32 @@ def test_existing_invitation( ) -> None: """Test inviting someone twice.""" response = rest_user_client.post( - resource_url, - {"email": "hello@example.com"}, + resource_url, {"email": "hello@example.com"} ) assert response.status_code == 201, response.data response = rest_user_client.post( - resource_url, - {"email": "hello@example.com"}, + resource_url, {"email": "hello@example.com"} ) assert response.status_code == 400, response.data assert "already been invited" in response.data["email"], response.data + + def test_uninvite_non_existing( + self, uninvite_url: str, rest_user_client: APIClient + ) -> None: + """Assert nothing weird happens when uninviting an invalid email.""" + response = rest_user_client.post( + uninvite_url, {"email": "hello@example.com"} + ) + assert response.status_code == 400, response.data + + def test_uninvite_existing_user( + self, + uninvite_url: str, + rest_user_client: APIClient, + workspace_user: WorkspaceUser, + ) -> None: + """Assert nothing weird happens when uninviting an invalid email.""" + response = rest_user_client.post( + uninvite_url, {"email": workspace_user.user.email} + ) + assert response.status_code == 400, response.data diff --git a/backend/projectify/workspace/urls.py b/backend/projectify/workspace/urls.py index b0f07ded7..695e562e6 100644 --- a/backend/projectify/workspace/urls.py +++ b/backend/projectify/workspace/urls.py @@ -41,8 +41,9 @@ ) from .views.workspace import ( InviteUserToWorkspace, + UninviteUserFromWorkspace, + UserWorkspaces, WorkspaceCreate, - WorkspaceList, WorkspacePictureUploadView, WorkspaceReadUpdate, ) @@ -53,12 +54,43 @@ app_name = "workspace" workspace_patterns = ( + # Create + path( + "", + WorkspaceCreate.as_view(), + name="create", + ), # Read + Update path( "", WorkspaceReadUpdate.as_view(), name="read-update", ), + # Read + path( + "user-workspaces/", + UserWorkspaces.as_view(), + name="user-workspaces", + ), + # Update + # Delete + # RPC + path( + "/picture-upload", + WorkspacePictureUploadView.as_view(), + name="upload-picture", + ), + path( + "/invite-workspace-user", + InviteUserToWorkspace.as_view(), + name="invite-workspace-user", + ), + path( + "/uninvite-workspace-user", + UninviteUserFromWorkspace.as_view(), + name="uninvite-workspace-user", + ), + # Related # Archived workspace boards path( "/archived-workspace-boards/", @@ -128,6 +160,7 @@ # Read, Update, Delete path( "", + # TODO Rename all views to use standard CRUD terminology TaskRetrieveUpdateDelete.as_view(), name="read-update-delete", ), @@ -161,39 +194,12 @@ # Delete ) -# TODO Rename all views to use standard CRUD terminology urlpatterns = ( # Workspace path( "workspace/", include((workspace_patterns, "workspaces")), ), - # TODO put the below paths into workspace_patterns as well - # Create - path( - "workspaces/", - WorkspaceCreate.as_view(), - name="workspace-create", - ), - # Read - path( - "user/workspaces/", - WorkspaceList.as_view(), - name="workspace-list", - ), - # Update - # Delete - # RPC - path( - "workspace//picture-upload", - WorkspacePictureUploadView.as_view(), - name="workspace-picture-upload", - ), - path( - "workspace//invite-user", - InviteUserToWorkspace.as_view(), - name="workspace-invite-user", - ), # WorkspaceUser path( "workspace-user/", @@ -216,8 +222,6 @@ "task/", include((task_patterns, "tasks")), ), - # TODO put into task_patterns - # Create # Label path("label/", include((label_patterns, "labels"))), ) diff --git a/backend/projectify/workspace/views/workspace.py b/backend/projectify/workspace/views/workspace.py index fe511e07f..f72f40932 100644 --- a/backend/projectify/workspace/views/workspace.py +++ b/backend/projectify/workspace/views/workspace.py @@ -31,7 +31,11 @@ from rest_framework.response import ( Response, ) -from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED +from rest_framework.status import ( + HTTP_200_OK, + HTTP_201_CREATED, + HTTP_204_NO_CONTENT, +) from projectify.workspace.selectors.quota import workspace_get_all_quotas @@ -57,6 +61,7 @@ ) from ..services.workspace_user_invite import ( workspace_user_invite_create, + workspace_user_invite_delete, ) @@ -88,7 +93,7 @@ def post(self, request: Request) -> Response: # Read -class WorkspaceList(views.APIView): +class UserWorkspaces(views.APIView): """List all workspaces for a user.""" def get(self, request: Request) -> Response: @@ -154,11 +159,10 @@ class WorkspacePictureUploadView(views.APIView): parser_classes = (parsers.MultiPartParser,) - def post(self, request: Request, uuid: UUID) -> Response: + def post(self, request: Request, workspace_uuid: UUID) -> Response: """Handle POST.""" workspace = workspace_find_by_workspace_uuid( - who=request.user, - workspace_uuid=uuid, + who=request.user, workspace_uuid=workspace_uuid ) if workspace is None: raise NotFound( @@ -182,12 +186,10 @@ class InputSerializer(serializers.Serializer): email = serializers.EmailField() - def post(self, request: Request, uuid: UUID) -> Response: + def post(self, request: Request, workspace_uuid: UUID) -> Response: """Handle POST.""" - user = request.user workspace = workspace_find_by_workspace_uuid( - workspace_uuid=uuid, - who=user, + workspace_uuid=workspace_uuid, who=request.user ) if workspace is None: raise NotFound(_("No workspace found for this UUID")) @@ -197,7 +199,7 @@ def post(self, request: Request, uuid: UUID) -> Response: email: str = serializer.validated_data["email"] try: workspace_user_invite_create( - who=user, workspace=workspace, email_or_user=email + who=request.user, workspace=workspace, email_or_user=email ) except UserAlreadyInvited: raise serializers.ValidationError( @@ -218,3 +220,28 @@ def post(self, request: Request, uuid: UUID) -> Response: } ) return Response(data=serializer.data, status=HTTP_201_CREATED) + + +class UninviteUserFromWorkspace(views.APIView): + """Remove a user invitation.""" + + class InputSerializer(serializers.Serializer): + """Accept email.""" + + email = serializers.EmailField() + + def post(self, request: Request, workspace_uuid: UUID) -> Response: + """Handle POST.""" + workspace = workspace_find_by_workspace_uuid( + workspace_uuid=workspace_uuid, who=request.user + ) + if workspace is None: + raise NotFound(_("No workspace found for this UUID")) + serializer = self.InputSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + workspace_user_invite_delete( + workspace=workspace, + who=request.user, + email=serializer.validated_data["email"], + ) + return Response(data=serializer.data, status=HTTP_204_NO_CONTENT) diff --git a/docs/frontend/testing.md b/docs/frontend/testing.md new file mode 100644 index 000000000..7f9ce449e --- /dev/null +++ b/docs/frontend/testing.md @@ -0,0 +1,9 @@ +# Naughty input data for testing forms + +## Email + +Email address that Firefox accepts, but will most likely be too long for +backend. As a matter of fact, I provoked a 500 with this one for the user +invite: + +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@example.com diff --git a/frontend/src/lib/figma/overlays/constructive/InviteWorkspaceUser.svelte b/frontend/src/lib/figma/overlays/constructive/InviteWorkspaceUser.svelte index 1f3e4e8fe..359b44c21 100644 --- a/frontend/src/lib/figma/overlays/constructive/InviteWorkspaceUser.svelte +++ b/frontend/src/lib/figma/overlays/constructive/InviteWorkspaceUser.svelte @@ -1,6 +1,6 @@