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