diff --git a/apps/api/plane/graphql/mutations/page/workspace.py b/apps/api/plane/graphql/mutations/page/workspace.py index 2dcff6f110..b853c76977 100644 --- a/apps/api/plane/graphql/mutations/page/workspace.py +++ b/apps/api/plane/graphql/mutations/page/workspace.py @@ -12,8 +12,9 @@ from strawberry.types import Info # Module imports from plane.db.models import Page, ProjectMember, WorkspaceMember -from plane.graphql.permissions.workspace import WorkspaceBasePermission +from plane.graphql.permissions.workspace import WorkspacePermission from plane.graphql.types.page import PageType +from plane.graphql.utils.roles import Roles @sync_to_async @@ -39,7 +40,11 @@ def get_project_member(slug: str, project: str, user_id: str): @strawberry.type class WorkspacePageMutation: @strawberry.mutation( - extensions=[PermissionExtension(permissions=[WorkspaceBasePermission()])] + extensions=[ + PermissionExtension( + permissions=[WorkspacePermission(roles=[Roles.ADMIN, Roles.MEMBER])] + ) + ] ) async def updateWorkspacePage( self, diff --git a/apps/api/plane/graphql/mutations/project.py b/apps/api/plane/graphql/mutations/project.py index dcfc189cf9..e6bd79b9fd 100644 --- a/apps/api/plane/graphql/mutations/project.py +++ b/apps/api/plane/graphql/mutations/project.py @@ -1,35 +1,38 @@ -# Strawberry imports -import strawberry -from strawberry.types import Info -from strawberry.scalars import JSON -from strawberry.permission import PermissionExtension -from strawberry.exceptions import GraphQLError - -# Third-party imports -from asgiref.sync import sync_to_async +# Python imports from typing import Optional +# Third-party imports +import strawberry +from asgiref.sync import sync_to_async + +# Strawberry imports +from strawberry.exceptions import GraphQLError +from strawberry.permission import PermissionExtension +from strawberry.scalars import JSON +from strawberry.types import Info + # Module imports +from plane.db.models import ( + IssueUserProperty, + Project, + ProjectMember, + State, + UserFavorite, + Workspace, + WorkspaceMember, +) +from plane.ee.models import ProjectFeature +from plane.graphql.permissions.project import ( + ProjectAdminPermission, + ProjectBasePermission, + ProjectMemberPermission, +) from plane.graphql.permissions.workspace import ( WorkspaceBasePermission, WorkspacePermission, ) -from plane.graphql.permissions.project import ( - ProjectMemberPermission, - ProjectBasePermission, - ProjectAdminPermission, -) from plane.graphql.types.project import ProjectType from plane.graphql.utils.roles import Roles -from plane.db.models import ( - Workspace, - WorkspaceMember, - Project, - ProjectMember, - UserFavorite, - IssueUserProperty, - State, -) @strawberry.type @@ -52,9 +55,13 @@ class ProjectMutation: cover_image: Optional[str] = None, project_lead: Optional[strawberry.ID] = None, logo_props: Optional[JSON] = {}, - page_view: Optional[bool] = False, + page_view: Optional[bool] = True, + module_view: Optional[bool] = True, + cycle_view: Optional[bool] = True, + issue_views_view: Optional[bool] = True, ) -> ProjectType: workspace = await sync_to_async(Workspace.objects.get)(slug=slug) + try: project = await sync_to_async(Project.objects.create)( name=name, @@ -64,6 +71,9 @@ class ProjectMutation: logo_props=logo_props, cover_image=cover_image, page_view=page_view, + module_view=module_view, + cycle_view=cycle_view, + issue_views_view=issue_views_view, workspace=workspace, ) except Exception: @@ -143,6 +153,11 @@ class ProjectMutation: ] ) + # Project feature + _ = await sync_to_async(ProjectFeature.objects.create)( + workspace_id=workspace.id, project_id=project.id + ) + return project @strawberry.mutation( @@ -158,6 +173,9 @@ class ProjectMutation: network: Optional[int] = None, logo_props: Optional[JSON] = None, page_view: Optional[bool] = None, + module_view: Optional[bool] = None, + cycle_view: Optional[bool] = None, + issue_views_view: Optional[bool] = None, cover_image: Optional[str] = None, ) -> ProjectType: project = await sync_to_async(Project.objects.get)(id=id) @@ -173,8 +191,12 @@ class ProjectMutation: project.logo_props = logo_props if page_view is not None: project.page_view = page_view - if page_view is not None: - project.page_view = page_view + if module_view is not None: + project.module_view = module_view + if cycle_view is not None: + project.cycle_view = cycle_view + if issue_views_view is not None: + project.issue_views_view = issue_views_view if cover_image is not None: project.cover_image = cover_image await sync_to_async(project.save)() diff --git a/apps/api/plane/graphql/queries/issues/shortened_url.py b/apps/api/plane/graphql/queries/issues/shortened_url.py index 2a51b44ab1..1869643507 100644 --- a/apps/api/plane/graphql/queries/issues/shortened_url.py +++ b/apps/api/plane/graphql/queries/issues/shortened_url.py @@ -9,6 +9,7 @@ from strawberry.types import Info # Module Imports from plane.db.models import Issue, Project, ProjectMember +from plane.graphql.helpers.teamspace import project_member_filter_via_teamspaces_async from plane.graphql.permissions.workspace import WorkspacePermission from plane.graphql.types.issues.meta import IssueShortenedMetaInfo from plane.graphql.utils.roles import Roles @@ -72,6 +73,9 @@ class IssueShortenedMetaInfoQuery: async def issue_shortened_meta_info( self, info: Info, slug: str, work_item_identifier: str ) -> IssueShortenedMetaInfo: + user = info.context.user + user_id = str(user.id) + workspace_slug = slug project_identifier = None issue_sequence = None @@ -87,15 +91,25 @@ class IssueShortenedMetaInfoQuery: error_extensions = {"code": "ISSUE_NOT_FOUND", "statusCode": 404} raise GraphQLError(message, extensions=error_extensions) + project_id = await get_project_id(workspace_slug, project_identifier) + is_project_member = await project_member_exists( workspace_slug, project_identifier, info.context.user ) if not is_project_member: - message = "User does not have permission to access this project." - error_extensions = {"code": "UNAUTHORIZED", "statusCode": 403} - raise GraphQLError(message, extensions=error_extensions) - - project_id = await get_project_id(workspace_slug, project_identifier) + # validate teamspace membership + project_teamspace_filter = await project_member_filter_via_teamspaces_async( + user_id=user_id, + workspace_slug=workspace_slug, + ) + teamspace_project_ids = project_teamspace_filter.teamspace_project_ids + if ( + not teamspace_project_ids + or str(project_id) not in teamspace_project_ids + ): + message = "User does not have permission to access this project." + error_extensions = {"code": "UNAUTHORIZED", "statusCode": 403} + raise GraphQLError(message, extensions=error_extensions) issue_id, is_epic = await get_issue_id( workspace_slug, project_id, issue_sequence diff --git a/apps/api/plane/graphql/queries/page/project.py b/apps/api/plane/graphql/queries/page/project.py index 4e955bbba5..112c7992d8 100644 --- a/apps/api/plane/graphql/queries/page/project.py +++ b/apps/api/plane/graphql/queries/page/project.py @@ -64,16 +64,17 @@ class PageQuery: ) # Get shared page ids for private and shared pages - if type in ["private", "shared"]: - shared_pages = await sync_to_async(list)( - PageUser.objects.filter( - workspace__slug=slug, - project__id=project, - ).values_list("page_id", flat=True) + shared_pages_data = await sync_to_async(list)( + PageUser.objects.filter(workspace__slug=slug, project__id=project).values( + "page_id", "user_id" ) - shared_page_ids = list(shared_pages) - else: - shared_page_ids = [] + ) + shared_page_ids = list(set(str(item["page_id"]) for item in shared_pages_data)) + pages_shared_with_user = [ + str(item["page_id"]) + for item in shared_pages_data + if str(item["user_id"]) == user_id + ] # Filter archived pages for all, public and shared pages if type != "archived": @@ -90,11 +91,15 @@ class PageQuery: page_base_query = page_base_query.filter(access=0) elif type == "private": page_base_query = page_base_query.filter( - Q(access=1) & ~Q(id__in=shared_page_ids) - ).filter(owned_by_id=user_id) + access=1, owned_by_id=user_id + ).exclude(id__in=shared_page_ids) elif type == "shared": page_base_query = page_base_query.filter( - Q(access=1) & Q(id__in=shared_page_ids) + Q(access=1) + & ( + Q(id__in=pages_shared_with_user) + | (Q(owned_by_id=user_id) & Q(id__in=shared_page_ids)) + ) ) # Subquery for UserFavorite diff --git a/apps/api/plane/graphql/queries/page/user.py b/apps/api/plane/graphql/queries/page/user.py index 643c0902de..2a5e273be6 100644 --- a/apps/api/plane/graphql/queries/page/user.py +++ b/apps/api/plane/graphql/queries/page/user.py @@ -58,17 +58,18 @@ class UserPageQuery: .filter(project_teamspace_filter_query) ) - # Get shared page ids - if type in ["private", "shared"]: - shared_pages = await sync_to_async(list)( - PageUser.objects.filter( - workspace__slug=slug, - project_id__isnull=False, - ).values_list("page_id", flat=True) - ) - shared_page_ids = list(shared_pages) - else: - shared_page_ids = [] + shared_pages_data = await sync_to_async(list)( + PageUser.objects.filter( + workspace__slug=slug, + project_id__isnull=False, + ).values("page_id", "user_id") + ) + shared_page_ids = list(set(str(item["page_id"]) for item in shared_pages_data)) + pages_shared_with_user = [ + str(item["page_id"]) + for item in shared_pages_data + if str(item["user_id"]) == user_id + ] # Filter archived pages if type != "archived": @@ -85,11 +86,15 @@ class UserPageQuery: page_base_query = page_base_query.filter(access=0) elif type == "private": page_base_query = page_base_query.filter( - Q(access=1) & ~Q(id__in=shared_page_ids) - ).filter(owned_by_id=user_id) + access=1, owned_by_id=user_id + ).exclude(id__in=shared_page_ids) elif type == "shared": page_base_query = page_base_query.filter( - Q(access=1) & Q(id__in=shared_page_ids) + Q(access=1) + & ( + Q(id__in=pages_shared_with_user) + | (Q(owned_by_id=user_id) & Q(id__in=shared_page_ids)) + ) ) subquery = UserFavorite.objects.filter( diff --git a/apps/api/plane/graphql/queries/page/workspace.py b/apps/api/plane/graphql/queries/page/workspace.py index 6b1270c0ee..86ae252215 100644 --- a/apps/api/plane/graphql/queries/page/workspace.py +++ b/apps/api/plane/graphql/queries/page/workspace.py @@ -25,13 +25,18 @@ from plane.graphql.types.page import PageType from plane.graphql.types.paginator import PaginatorResponse from plane.graphql.utils.feature_flag import validate_feature_flag from plane.graphql.utils.paginator import paginate +from plane.graphql.utils.roles import Roles # workspace level queries @strawberry.type class WorkspacePageQuery: @strawberry.field( - extensions=[PermissionExtension(permissions=[WorkspacePermission()])] + extensions=[ + PermissionExtension( + permissions=[WorkspacePermission(roles=[Roles.ADMIN, Roles.MEMBER])] + ) + ] ) async def workspacePages( self, @@ -61,16 +66,15 @@ class WorkspacePageQuery: .filter(moved_to_page__isnull=True) ) - # Get shared page ids - if type in ["private", "shared"]: - shared_pages = await sync_to_async(list)( - PageUser.objects.filter( - workspace__slug=slug, - ).values_list("page_id", flat=True) - ) - shared_page_ids = list(shared_pages) - else: - shared_page_ids = [] + shared_pages_data = await sync_to_async(list)( + PageUser.objects.filter(workspace__slug=slug).values("page_id", "user_id") + ) + shared_page_ids = list(set(str(item["page_id"]) for item in shared_pages_data)) + pages_shared_with_user = [ + str(item["page_id"]) + for item in shared_pages_data + if str(item["user_id"]) == user_id + ] # Filter archived pages if type != "archived": @@ -87,11 +91,15 @@ class WorkspacePageQuery: page_base_query = page_base_query.filter(access=0) elif type == "private": page_base_query = page_base_query.filter( - Q(access=1) & ~Q(id__in=shared_page_ids) - ).filter(owned_by_id=user_id) + access=1, owned_by_id=user_id + ).exclude(id__in=shared_page_ids) elif type == "shared": page_base_query = page_base_query.filter( - Q(access=1) & Q(id__in=shared_page_ids) + Q(access=1) + & ( + Q(id__in=pages_shared_with_user) + | (Q(owned_by_id=user_id) & Q(id__in=shared_page_ids)) + ) ) # Build subquery for UserFavorite @@ -103,9 +111,10 @@ class WorkspacePageQuery: ) pages = await sync_to_async(list)( - page_base_query.select_related("workspace", "owned_by") + page_base_query.distinct() + .select_related("workspace", "owned_by") + .prefetch_related("projects") .annotate(is_favorite=Exists(subquery)) - .order_by("-created_at") ) return paginate(results_object=pages, cursor=cursor) @@ -113,7 +122,10 @@ class WorkspacePageQuery: @strawberry.field( extensions=[ PermissionExtension( - permissions=[WorkspacePermission(), WorkspaceSharedPagePermission()] + permissions=[ + WorkspacePermission(roles=[Roles.ADMIN, Roles.MEMBER]), + WorkspaceSharedPagePermission(), + ] ) ] ) diff --git a/apps/api/plane/graphql/types/page.py b/apps/api/plane/graphql/types/page.py index c83c7bf812..a616196612 100644 --- a/apps/api/plane/graphql/types/page.py +++ b/apps/api/plane/graphql/types/page.py @@ -5,10 +5,11 @@ from typing import Optional # Third-party library imports import strawberry import strawberry_django +from asgiref.sync import sync_to_async # Strawberry imports -from asgiref.sync import sync_to_async from strawberry.scalars import JSON +from strawberry.types import Info # Module imports from plane.db.models import Page @@ -17,6 +18,25 @@ from plane.graphql.types.project import ProjectLiteType from plane.graphql.utils.timezone import user_timezone_converter +@sync_to_async +def shared_page_access(user_id: str, workspace_slug: str, page_id: str, owner_id: str): + shared_pages_data = PageUser.objects.filter( + workspace__slug=workspace_slug, page_id=page_id + ).values("user_id", "access") + shared_pages_data = list(shared_pages_data) + + page_access = 0 + if owner_id == user_id: + page_access = 2 + else: + for item in shared_pages_data: + if str(item["user_id"]) == user_id: + page_access = item["access"] + break + + return (len(shared_pages_data) > 0, page_access) + + @strawberry_django.type(Page) class PageType: id: strawberry.ID @@ -79,38 +99,46 @@ class PageType: return converted_date @strawberry.field - async def is_shared(self) -> bool: + async def is_shared(self, info: Info) -> bool: if self.access == 0: return False + user = info.context.user + user_id = str(user.id) + workspace_slug = self.workspace.slug - page_id = self.id - shared_page = await sync_to_async( - lambda: PageUser.objects.filter( - workspace__slug=workspace_slug, - page_id=page_id, - ).first() - )() - if shared_page: + page_id = str(self.id) + + shared_page_count, _ = await shared_page_access( + workspace_slug=workspace_slug, + page_id=page_id, + user_id=user_id, + owner_id=str(self.owned_by_id), + ) + if shared_page_count > 0: return True return False @strawberry.field - async def is_shared_access(self) -> int: + async def is_shared_access(self, info: Info) -> int: if self.access == 0: return 0 + user = info.context.user + user_id = str(user.id) + workspace_slug = self.workspace.slug - page_id = self.id - shared_page = await sync_to_async( - lambda: PageUser.objects.filter( - workspace__slug=workspace_slug, - page_id=page_id, - ).first() - )() - if shared_page: - shared_page_access = shared_page.access - return shared_page_access + page_id = str(self.id) + + shared_page_count, page_access = await shared_page_access( + workspace_slug=workspace_slug, + page_id=page_id, + user_id=user_id, + owner_id=str(self.owned_by_id), + ) + + if shared_page_count > 0: + return page_access return 0 # @strawberry.field