Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/api/plane/api/urls/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,17 @@

from django.urls import path

from plane.api.views import UserEndpoint
from plane.api.views import UserEndpoint, UserWorkspacesEndpoint

urlpatterns = [
path(
"users/me/",
UserEndpoint.as_view(http_method_names=["get"]),
name="users",
),
path(
"users/me/workspaces/",
UserWorkspacesEndpoint.as_view(http_method_names=["get"]),
name="users-workspaces",
),
]
2 changes: 1 addition & 1 deletion apps/api/plane/api/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@

from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint

from .user import UserEndpoint
from .user import UserEndpoint, UserWorkspacesEndpoint

from .invite import WorkspaceInvitationsViewset

Expand Down
53 changes: 50 additions & 3 deletions apps/api/plane/api/views/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
Comment thread
Syed-Ali-Abbas-Zaidi marked this conversation as resolved.
serializer = WorkspaceLiteSerializer(workspaces, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
109 changes: 109 additions & 0 deletions apps/api/plane/tests/contract/api/test_user_workspaces.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# 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 django.utils import timezone
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.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
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_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)
assert response.status_code in (
status.HTTP_401_UNAUTHORIZED,
status.HTTP_403_FORBIDDEN,
)