From 57aff09fbb15e3cead8eb4a3c462bee831263d7b Mon Sep 17 00:00:00 2001 From: guru_sainath Date: Tue, 24 Sep 2024 14:16:08 +0530 Subject: [PATCH] [MOBIL-168] issue relations (#1206) * chore: issue relation query * chore: import error * chore: blocking issues list update * chore: updated search and remove in issue relation * chore: updated add and remove relation * chore: created issue comment mutation * chore: replaced the project filter with workspace filter --- .../graphql/mutations/issues/__init__.py | 2 + .../plane/graphql/mutations/issues/comment.py | 47 ++++++++ .../graphql/mutations/issues/relation.py | 98 +++++++++++++++ .../plane/graphql/queries/issues/__init__.py | 2 + .../plane/graphql/queries/issues/relation.py | 96 +++++++++++++++ .../plane/graphql/queries/issues/search.py | 112 ++++++++++++++++++ apiserver/plane/graphql/schema.py | 6 + .../plane/graphql/types/issues/__init__.py | 0 .../plane/graphql/types/issues/relation.py | 12 ++ 9 files changed, 375 insertions(+) create mode 100644 apiserver/plane/graphql/mutations/issues/__init__.py create mode 100644 apiserver/plane/graphql/mutations/issues/comment.py create mode 100644 apiserver/plane/graphql/mutations/issues/relation.py create mode 100644 apiserver/plane/graphql/queries/issues/__init__.py create mode 100644 apiserver/plane/graphql/queries/issues/relation.py create mode 100644 apiserver/plane/graphql/queries/issues/search.py create mode 100644 apiserver/plane/graphql/types/issues/__init__.py create mode 100644 apiserver/plane/graphql/types/issues/relation.py diff --git a/apiserver/plane/graphql/mutations/issues/__init__.py b/apiserver/plane/graphql/mutations/issues/__init__.py new file mode 100644 index 0000000000..782d6bb1ea --- /dev/null +++ b/apiserver/plane/graphql/mutations/issues/__init__.py @@ -0,0 +1,2 @@ +from .relation import IssueRelationMutation +from .comment import IssueCommentMutation \ No newline at end of file diff --git a/apiserver/plane/graphql/mutations/issues/comment.py b/apiserver/plane/graphql/mutations/issues/comment.py new file mode 100644 index 0000000000..48fe921428 --- /dev/null +++ b/apiserver/plane/graphql/mutations/issues/comment.py @@ -0,0 +1,47 @@ +# Strawberry imports +import strawberry +from strawberry.types import Info +from strawberry.permission import PermissionExtension + +# Third-party imports +from asgiref.sync import sync_to_async + + +# Module imports +from plane.graphql.permissions.project import ProjectBasePermission +from plane.db.models import Workspace, IssueComment + + +@strawberry.type +class IssueCommentMutation: + # adding issue comment + @strawberry.mutation( + extensions=[PermissionExtension(permissions=[ProjectBasePermission()])] + ) + async def addIssueComment( + self, + info: Info, + slug: str, + project: strawberry.ID, + issue: strawberry.ID, + comment_html: str, + ) -> bool: + workspace_details = await sync_to_async( + Workspace.objects.filter(slug=slug).first + )() + if not workspace_details: + return False + + await sync_to_async( + lambda: IssueComment.objects.create( + workspace_id=workspace_details.id, + project_id=project, + issue_id=issue, + comment_html=comment_html, + actor=info.context.user, + created_by=info.context.user, + updated_by=info.context.user, + ) + )() + + return True diff --git a/apiserver/plane/graphql/mutations/issues/relation.py b/apiserver/plane/graphql/mutations/issues/relation.py new file mode 100644 index 0000000000..4d14c9ae9b --- /dev/null +++ b/apiserver/plane/graphql/mutations/issues/relation.py @@ -0,0 +1,98 @@ +# Strawberry imports +import strawberry +from strawberry.types import Info +from strawberry.permission import PermissionExtension + +# Third-party imports +from asgiref.sync import sync_to_async + + +# Module imports +from plane.graphql.permissions.project import ProjectBasePermission +from plane.db.models import Workspace, IssueRelation + + +@strawberry.type +class IssueRelationMutation: + # adding issue relation + @strawberry.mutation( + extensions=[PermissionExtension(permissions=[ProjectBasePermission()])] + ) + async def addIssueRelation( + self, + info: Info, + slug: str, + project: strawberry.ID, + issue: strawberry.ID, + relation_type: str, + related_issue_ids: list[strawberry.ID], + ) -> bool: + workspace_details = await sync_to_async( + Workspace.objects.filter(slug=slug).first + )() + if not workspace_details: + return False + + issue_relations = [ + IssueRelation( + issue_id=( + related_issue_id if relation_type == "blocking" else issue + ), + related_issue_id=( + issue if relation_type == "blocking" else related_issue_id + ), + relation_type=( + "blocked_by" + if relation_type == "blocking" + else relation_type + ), + project_id=project, + workspace_id=workspace_details.id, + created_by=info.context.user, + updated_by=info.context.user, + ) + for related_issue_id in related_issue_ids + ] + + await sync_to_async( + lambda: IssueRelation.objects.bulk_create( + issue_relations, + batch_size=10, + ignore_conflicts=True, + ) + )() + + return True + + # removing issue relation + @strawberry.mutation( + extensions=[PermissionExtension(permissions=[ProjectBasePermission()])] + ) + async def removeIssueRelation( + self, + info: Info, + slug: str, + project: strawberry.ID, + issue: strawberry.ID, + relation_type: str, + related_issue: strawberry.ID, + ) -> bool: + issue_relation = await sync_to_async( + lambda: IssueRelation.objects.get( + workspace__slug=slug, + project_id=project, + issue_id=( + related_issue if relation_type == "blocking" else issue + ), + related_issue_id=( + issue if relation_type == "blocking" else related_issue + ), + ) + )() + + if not issue_relation: + return False + + await sync_to_async(lambda: issue_relation.delete())() + + return True diff --git a/apiserver/plane/graphql/queries/issues/__init__.py b/apiserver/plane/graphql/queries/issues/__init__.py new file mode 100644 index 0000000000..10a753afc8 --- /dev/null +++ b/apiserver/plane/graphql/queries/issues/__init__.py @@ -0,0 +1,2 @@ +from .relation import IssueRelationQuery +from .search import IssuesSearchQuery \ No newline at end of file diff --git a/apiserver/plane/graphql/queries/issues/relation.py b/apiserver/plane/graphql/queries/issues/relation.py new file mode 100644 index 0000000000..d51a5529e3 --- /dev/null +++ b/apiserver/plane/graphql/queries/issues/relation.py @@ -0,0 +1,96 @@ +# Third-Party Imports +import strawberry + + +# Python Standard Library Imports +from asgiref.sync import sync_to_async + +# Django Imports +from django.db.models import Q, Value, CharField + +# Strawberry Imports +from strawberry.types import Info +from strawberry.permission import PermissionExtension + +# Module Imports +from plane.graphql.permissions.project import ProjectBasePermission +from plane.db.models import IssueRelation, Issue +from plane.graphql.types.issues.relation import IssueRelationType + + +@strawberry.type +class IssueRelationQuery: + # getting issue relation issues + @strawberry.field( + extensions=[PermissionExtension(permissions=[ProjectBasePermission()])] + ) + async def issueRelation( + self, + info: Info, + slug: str, + project: strawberry.ID, + issue: strawberry.ID, + ) -> IssueRelationType: + # construct the issue relation query + issue_relation_query = ( + IssueRelation.objects.filter(workspace__slug=slug) + .filter(project__id=project) + .filter( + project__project_projectmember__member=info.context.user, + project__project_projectmember__is_active=True, + ) + .filter(Q(issue_id=issue) | Q(related_issue=issue)) + .select_related("project") + .select_related("workspace") + .select_related("issue") + .order_by("-created_at") + .distinct() + ) + + # getting all blocking issues + blocking_issue_ids = await sync_to_async(list)( + issue_relation_query.filter( + relation_type="blocked_by", related_issue_id=issue + ).values_list("issue_id", flat=True) + ) + + # getting all blocked by issues + blocked_by_issue_ids = await sync_to_async(list)( + issue_relation_query.filter( + relation_type="blocked_by", issue_id=issue + ).values_list("related_issue_id", flat=True) + ) + + # constructing the issue query + issue_queryset = ( + Issue.issue_objects.filter( + workspace__slug=slug, project_id=project + ) + .filter( + project__project_projectmember__member=info.context.user, + project__project_projectmember__is_active=True, + ) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels") + ) + + # getting all blocking issues + blocking_issues = await sync_to_async(list)( + issue_queryset.filter(id__in=blocking_issue_ids).annotate( + relation_type=Value("blocking", output_field=CharField()) + ) + ) + + # getting all blocked by issues + blocked_by_issues = await sync_to_async(list)( + issue_queryset.filter(id__in=blocked_by_issue_ids).annotate( + relation_type=Value("blocked_by", output_field=CharField()) + ) + ) + + relation_response = IssueRelationType( + blocking=blocking_issues, + blocked_by=blocked_by_issues, + ) + + return relation_response diff --git a/apiserver/plane/graphql/queries/issues/search.py b/apiserver/plane/graphql/queries/issues/search.py new file mode 100644 index 0000000000..fd98050314 --- /dev/null +++ b/apiserver/plane/graphql/queries/issues/search.py @@ -0,0 +1,112 @@ +# Python imports +import re + +# Third-Party Imports +import strawberry + +from typing import Optional + +# Python Standard Library Imports +from asgiref.sync import sync_to_async + +# Django Imports +from django.db.models import Q + +# Strawberry Imports +from strawberry.types import Info +from strawberry.permission import PermissionExtension + +# Module Imports +from plane.graphql.permissions.workspace import WorkspaceBasePermission +from plane.db.models import Issue +from plane.graphql.types.issue import IssueLiteType + + +@strawberry.type +class IssuesSearchQuery: + # getting issues which are not related + @strawberry.field( + extensions=[ + PermissionExtension(permissions=[WorkspaceBasePermission()]) + ] + ) + + # getting issue relation issues + async def issuesSearch( + self, + info: Info, + slug: str, + project: Optional[strawberry.ID] = None, + module: Optional[strawberry.ID] = None, + cycle: Optional[strawberry.ID] = None, + issue: Optional[strawberry.ID] = None, + relation_type: Optional[bool] = False, + search: Optional[str] = None, + ) -> list[IssueLiteType]: + issue_queryset = Issue.issue_objects.filter( + workspace__slug=slug, + project__project_projectmember__member=info.context.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + ) + + # workspace issues + if project: + issue_queryset = issue_queryset.filter(project_id=project) + + # module issues + if module: + issue_queryset = issue_queryset.filter( + issue_module__module_id=module + ) + + # cycle issues + if cycle: + issue_queryset = issue_queryset.filter(issue_cycle__cycle_id=cycle) + + # issue relation issues + if relation_type and issue: + issue_queryset = issue_queryset.filter( + ~Q(pk=issue), + ~Q( + issue_related__issue=issue, + issue_related__deleted_at__isnull=True, + ), + ~Q( + issue_relation__related_issue=issue, + issue_related__deleted_at__isnull=True, + ), + ) + + # apply search filter + q = Q() + if search: + fields = ["name", "sequence_id", "project__identifier"] + for field in fields: + if field == "sequence_id": + # Match whole integers only (exclude decimal numbers) + sequences = re.findall(r"\b\d+\b", search) + for sequence_id in sequences: + q |= Q(**{"sequence_id": sequence_id}) + else: + q |= Q(**{f"{field}__icontains": search}) + + issues = await sync_to_async( + lambda: list( + issue_queryset.filter(q) + .distinct() + .values( + "id", + "sequence_id", + "name", + "project", + "project__identifier", + ) + ) + )() + + for issue in issues: + issue["project_identifier"] = issue["project__identifier"] + del issue["project__identifier"] + + return [IssueLiteType(**issue) for issue in issues] diff --git a/apiserver/plane/graphql/schema.py b/apiserver/plane/graphql/schema.py index e8bbf17140..8e7d66060a 100644 --- a/apiserver/plane/graphql/schema.py +++ b/apiserver/plane/graphql/schema.py @@ -42,6 +42,7 @@ from .queries.search import GlobalSearchQuery from .queries.attachment import IssueAttachmentQuery from .queries.link import IssueLinkQuery from .queries.estimate import EstimatePointQuery +from .queries.issues import IssueRelationQuery, IssuesSearchQuery # mutations from .mutations.workspace import WorkspaceMutation, WorkspaceInviteMutation @@ -71,6 +72,7 @@ from .mutations.module import ( ) from .mutations.link import IssueLinkMutation from .mutations.favorite import UserFavoriteMutation +from .mutations.issues import IssueRelationMutation, IssueCommentMutation # combined query class for all @@ -114,6 +116,8 @@ class Query( UserPageQuery, CycleIssueUserPropertyQuery, ModuleIssueUserPropertyQuery, + IssueRelationQuery, + IssuesSearchQuery, ): pass @@ -141,6 +145,8 @@ class Mutation( UserFavoriteMutation, CycleIssueUserPropertyMutation, ModuleIssueUserPropertyMutation, + IssueRelationMutation, + IssueCommentMutation, ): pass diff --git a/apiserver/plane/graphql/types/issues/__init__.py b/apiserver/plane/graphql/types/issues/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apiserver/plane/graphql/types/issues/relation.py b/apiserver/plane/graphql/types/issues/relation.py new file mode 100644 index 0000000000..70b63a7206 --- /dev/null +++ b/apiserver/plane/graphql/types/issues/relation.py @@ -0,0 +1,12 @@ +# Strawberry imports +import strawberry_django + +# Module Imports +from plane.db.models import IssueRelation +from plane.graphql.types.issue import IssuesType + + +@strawberry_django.type(IssueRelation) +class IssueRelationType: + blocking: list[IssuesType] + blocked_by: list[IssuesType]