From 4e3258a3bb9302503c8a56a2641910c9aab55c9e Mon Sep 17 00:00:00 2001 From: Syed Ali Abbas Zaidi Date: Wed, 29 Jul 2026 13:11:06 +0500 Subject: [PATCH 1/2] feat(api): add endpoint to list current user's workspaces --- apps/api/plane/api/urls/user.py | 7 +- apps/api/plane/api/views/__init__.py | 2 +- apps/api/plane/api/views/user.py | 53 +++++++++++- .../contract/api/test_user_workspaces.py | 81 +++++++++++++++++++ 4 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 apps/api/plane/tests/contract/api/test_user_workspaces.py diff --git a/apps/api/plane/api/urls/user.py b/apps/api/plane/api/urls/user.py index 33769b7c018..fa9d7f38a0e 100644 --- a/apps/api/plane/api/urls/user.py +++ b/apps/api/plane/api/urls/user.py @@ -4,7 +4,7 @@ from django.urls import path -from plane.api.views import UserEndpoint +from plane.api.views import UserEndpoint, UserWorkspacesEndpoint urlpatterns = [ path( @@ -12,4 +12,9 @@ UserEndpoint.as_view(http_method_names=["get"]), name="users", ), + path( + "users/me/workspaces/", + UserWorkspacesEndpoint.as_view(http_method_names=["get"]), + name="users-workspaces", + ), ] diff --git a/apps/api/plane/api/views/__init__.py b/apps/api/plane/api/views/__init__.py index 5e4660a7b2b..7378da43bac 100644 --- a/apps/api/plane/api/views/__init__.py +++ b/apps/api/plane/api/views/__init__.py @@ -67,7 +67,7 @@ from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint -from .user import UserEndpoint +from .user import UserEndpoint, UserWorkspacesEndpoint from .invite import WorkspaceInvitationsViewset diff --git a/apps/api/plane/api/views/user.py b/apps/api/plane/api/views/user.py index 02d29d11887..077465a4f62 100644 --- a/apps/api/plane/api/views/user.py +++ b/apps/api/plane/api/views/user.py @@ -5,12 +5,12 @@ # Third party imports from rest_framework import status from rest_framework.response import Response -from drf_spectacular.utils import OpenApiResponse +from drf_spectacular.utils import OpenApiExample, OpenApiResponse # Module imports -from plane.api.serializers import UserLiteSerializer +from plane.api.serializers import UserLiteSerializer, WorkspaceLiteSerializer from plane.api.views.base import BaseAPIView -from plane.db.models import User +from plane.db.models import User, Workspace, WorkspaceMember from plane.utils.openapi.decorators import user_docs from plane.utils.openapi import USER_EXAMPLE @@ -39,3 +39,50 @@ def get(self, request): """ serializer = UserLiteSerializer(request.user) return Response(serializer.data, status=status.HTTP_200_OK) + + +class UserWorkspacesEndpoint(BaseAPIView): + serializer_class = WorkspaceLiteSerializer + model = Workspace + use_read_replica = True + + @user_docs( + operation_id="list_current_user_workspaces", + summary="List current user's workspaces", + description=( + "List the workspaces the authenticated token can access, returning the id, name, " + "and slug of each workspace the user is an active member of. Useful for discovering " + "the workspace slug required by other API endpoints." + ), + responses={ + 200: OpenApiResponse( + description="List of workspaces accessible to the current token", + response=WorkspaceLiteSerializer(many=True), + examples=[ + OpenApiExample( + name="Workspaces", + value=[ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "My Workspace", + "slug": "my-workspace", + } + ], + ) + ], + ), + }, + ) + def get(self, request): + """List current user's workspaces + + List the workspaces the authenticated token can access, returning the id, name, and + slug of each workspace the user is an active member of. Useful for discovering the + workspace slug required by other API endpoints. + """ + workspace_ids = WorkspaceMember.objects.filter( + member=request.user, is_active=True + ).values_list("workspace_id", flat=True) + workspaces = Workspace.objects.filter(id__in=workspace_ids).order_by("name") + serializer = WorkspaceLiteSerializer(workspaces, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apps/api/plane/tests/contract/api/test_user_workspaces.py b/apps/api/plane/tests/contract/api/test_user_workspaces.py new file mode 100644 index 00000000000..42daa6cbde5 --- /dev/null +++ b/apps/api/plane/tests/contract/api/test_user_workspaces.py @@ -0,0 +1,81 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +"""Contract tests for the current user's workspaces endpoint. + +GET /api/v1/users/me/workspaces/ +""" + +import pytest +from rest_framework import status + +from plane.db.models import Workspace, WorkspaceMember + +URL = "/api/v1/users/me/workspaces/" + + +@pytest.fixture +def other_workspace(db, create_bot_user): + """A workspace the requesting user is not a member of.""" + return Workspace.objects.create( + name="Other Workspace", + owner=create_bot_user, + slug="other-workspace", + ) + + +@pytest.fixture +def inactive_workspace(db, create_user, create_bot_user): + """A workspace the requesting user's membership has been deactivated on.""" + workspace = Workspace.objects.create( + name="Inactive Workspace", + owner=create_bot_user, + slug="inactive-workspace", + ) + WorkspaceMember.objects.create( + workspace=workspace, + member=create_user, + role=20, + is_active=False, + ) + return workspace + + +@pytest.mark.contract +class TestUserWorkspaces: + @pytest.mark.django_db + def test_returns_workspaces_of_the_current_user(self, api_key_client, workspace): + response = api_key_client.get(URL) + assert response.status_code == status.HTTP_200_OK + slugs = {item["slug"] for item in response.data} + assert workspace.slug in slugs + + @pytest.mark.django_db + def test_returns_only_lite_fields(self, api_key_client, workspace): + response = api_key_client.get(URL) + assert response.status_code == status.HTTP_200_OK + item = response.data[0] + assert set(item.keys()) == {"id", "name", "slug"} + + @pytest.mark.django_db + def test_excludes_workspaces_the_user_is_not_a_member_of(self, api_key_client, workspace, other_workspace): + response = api_key_client.get(URL) + assert response.status_code == status.HTTP_200_OK + slugs = {item["slug"] for item in response.data} + assert other_workspace.slug not in slugs + + @pytest.mark.django_db + def test_excludes_inactive_memberships(self, api_key_client, workspace, inactive_workspace): + response = api_key_client.get(URL) + assert response.status_code == status.HTTP_200_OK + slugs = {item["slug"] for item in response.data} + assert inactive_workspace.slug not in slugs + + @pytest.mark.django_db + def test_requires_authentication(self, api_client, workspace): + response = api_client.get(URL) + assert response.status_code in ( + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ) From 10919ec7b1c2151c4ea6b8bbac16bddc90e24892 Mon Sep 17 00:00:00 2001 From: Syed Ali Abbas Zaidi Date: Wed, 29 Jul 2026 13:41:19 +0500 Subject: [PATCH 2/2] test(api): cover soft deleted workspaces in user workspaces endpoint --- .../contract/api/test_user_workspaces.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/apps/api/plane/tests/contract/api/test_user_workspaces.py b/apps/api/plane/tests/contract/api/test_user_workspaces.py index 42daa6cbde5..43ec468a940 100644 --- a/apps/api/plane/tests/contract/api/test_user_workspaces.py +++ b/apps/api/plane/tests/contract/api/test_user_workspaces.py @@ -8,6 +8,7 @@ """ import pytest +from django.utils import timezone from rest_framework import status from plane.db.models import Workspace, WorkspaceMember @@ -42,6 +43,26 @@ def inactive_workspace(db, create_user, create_bot_user): return workspace +@pytest.fixture +def deleted_workspace(db, create_user, create_bot_user): + """A soft deleted workspace the requesting user still has an active membership row on.""" + workspace = Workspace.objects.create( + name="Deleted Workspace", + owner=create_bot_user, + slug="deleted-workspace", + ) + WorkspaceMember.objects.create( + workspace=workspace, + member=create_user, + role=20, + is_active=True, + ) + # Stamp deleted_at directly instead of calling delete(), which queues a Celery + # task to soft delete related objects. + Workspace.all_objects.filter(id=workspace.id).update(deleted_at=timezone.now()) + return workspace + + @pytest.mark.contract class TestUserWorkspaces: @pytest.mark.django_db @@ -72,6 +93,13 @@ def test_excludes_inactive_memberships(self, api_key_client, workspace, inactive slugs = {item["slug"] for item in response.data} assert inactive_workspace.slug not in slugs + @pytest.mark.django_db + def test_excludes_soft_deleted_workspaces(self, api_key_client, workspace, deleted_workspace): + response = api_key_client.get(URL) + assert response.status_code == status.HTTP_200_OK + slugs = {item["slug"] for item in response.data} + assert deleted_workspace.slug not in slugs + @pytest.mark.django_db def test_requires_authentication(self, api_client, workspace): response = api_client.get(URL)