From de4d95d90c3cf7a941a14befa72ff2ae8528bcb5 Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Mon, 12 Aug 2024 18:57:33 +0530 Subject: [PATCH] chore: cycle and module endpoints (#790) * chore: module and cycles endpoint * chore: CRUD operation for cycle and modules * chore: resolvers in projects cycles --- apiserver/plane/graphql/mutations/cycle.py | 157 ++++++++++++++++++++ apiserver/plane/graphql/mutations/issue.py | 24 ++- apiserver/plane/graphql/mutations/module.py | 117 +++++++++++++++ apiserver/plane/graphql/queries/cycle.py | 82 ++++++++++ apiserver/plane/graphql/queries/issue.py | 11 +- apiserver/plane/graphql/queries/module.py | 82 ++++++++++ apiserver/plane/graphql/schema.py | 11 +- apiserver/plane/graphql/types/cycle.py | 76 ++++++++++ apiserver/plane/graphql/types/issue.py | 35 ++++- apiserver/plane/graphql/types/module.py | 72 +++++++++ apiserver/plane/graphql/types/project.py | 24 ++- 11 files changed, 680 insertions(+), 11 deletions(-) create mode 100644 apiserver/plane/graphql/mutations/cycle.py create mode 100644 apiserver/plane/graphql/mutations/module.py create mode 100644 apiserver/plane/graphql/queries/cycle.py create mode 100644 apiserver/plane/graphql/queries/module.py create mode 100644 apiserver/plane/graphql/types/cycle.py create mode 100644 apiserver/plane/graphql/types/module.py diff --git a/apiserver/plane/graphql/mutations/cycle.py b/apiserver/plane/graphql/mutations/cycle.py new file mode 100644 index 0000000000..f555c6c2d3 --- /dev/null +++ b/apiserver/plane/graphql/mutations/cycle.py @@ -0,0 +1,157 @@ +# Python imports +import json + +# Strawberry imports +import strawberry +from strawberry.types import Info +from strawberry.scalars import JSON +from strawberry.permission import PermissionExtension + +# Third-party imports +from asgiref.sync import sync_to_async + +# Django imports +from django.db.models import Q +from django.utils import timezone +from django.core import serializers + +# Module imports +from plane.graphql.permissions.project import ( + ProjectMemberPermission, +) +from plane.db.models import ( + CycleIssue, + Cycle, +) +from plane.graphql.bgtasks.issue_activity_task import issue_activity + + +@strawberry.type +class CycleIssueMutation: + @strawberry.mutation( + extensions=[ + PermissionExtension(permissions=[ProjectMemberPermission()]) + ] + ) + async def create_cycle_issue( + self, + info: Info, + slug: str, + project: strawberry.ID, + cycle: strawberry.ID, + issues: JSON, + ) -> bool: + # Fetch the cycle object asynchronously + cycle = await sync_to_async(Cycle.objects.get)( + workspace__slug=slug, project_id=project, pk=cycle + ) + + # Fetch existing CycleIssues for the given issues asynchronously + existing_cycle_issues = await sync_to_async( + lambda: list( + CycleIssue.objects.filter( + ~Q(cycle_id=cycle), issue_id__in=issues + ).all() + ) + )() + + # Extract issue_ids from the existing CycleIssue objects + existing_issue_ids = set( + map(lambda ci: str(ci.issue_id), existing_cycle_issues) + ) + + # Convert issues to a set of strings + new_issue_ids = set(map(str, issues)) + + # Determine new issues to create + new_issue_ids -= existing_issue_ids + + # Update existing CycleIssues to the new cycle asynchronously + await sync_to_async( + lambda: CycleIssue.objects.filter( + ~Q(cycle=cycle), issue_id__in=existing_issue_ids + ).update(cycle=cycle) + )() + + # Create new CycleIssues for new issues + new_cycle_issues = [ + CycleIssue( + project_id=project, + workspace_id=cycle.workspace_id, + created_by_id=info.context.user.id, + updated_by_id=info.context.user.id, + cycle=cycle, + issue_id=issue_id, + ) + for issue_id in new_issue_ids + ] + created_records = await sync_to_async(CycleIssue.objects.bulk_create)( + new_cycle_issues, batch_size=10 + ) + + update_cycle_issue_activity = [ + { + "old_cycle_id": str(cycle_issue.cycle_id), + "new_cycle_id": str(cycle.id), + "issue_id": str(cycle_issue.issue_id), + } + for cycle_issue in existing_cycle_issues + ] + + # Capture Issue Activity asynchronously + await sync_to_async(issue_activity.delay)( + type="cycle.activity.created", + requested_data=json.dumps({"cycles_list": list(issues)}), + actor_id=str(info.context.user.id), + issue_id=None, + project_id=str(project), + current_instance=json.dumps( + { + "updated_cycle_issues": update_cycle_issue_activity, + "created_cycle_issues": serializers.serialize( + "json", created_records + ), + } + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=info.context.request.META.get("HTTP_ORIGIN"), + ) + + return True + + @strawberry.mutation( + extensions=[ + PermissionExtension(permissions=[ProjectMemberPermission()]) + ] + ) + async def delete_cycle_issue( + self, + info: Info, + slug: str, + project: strawberry.ID, + cycle: strawberry.ID, + issue: strawberry.ID, + ) -> bool: + cycle_issue = await sync_to_async(CycleIssue.objects.filter)( + cycle_id=cycle, project_id=project, workspace__slug=slug, issue_id=issue + ) + await sync_to_async(issue_activity.delay)( + type="cycle.activity.deleted", + requested_data=json.dumps( + { + "cycle_id": str(cycle), + "issues": [str(issue)], + } + ), + actor_id=str(info.context.user.id), + issue_id=str(issue), + project_id=str(project), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=info.context.request.META.get("HTTP_ORIGIN"), + ) + await sync_to_async(cycle_issue.delete)() + + return True diff --git a/apiserver/plane/graphql/mutations/issue.py b/apiserver/plane/graphql/mutations/issue.py index a97491cc9a..d07d49eff3 100644 --- a/apiserver/plane/graphql/mutations/issue.py +++ b/apiserver/plane/graphql/mutations/issue.py @@ -6,6 +6,7 @@ import strawberry from strawberry.types import Info from strawberry.scalars import JSON from strawberry.permission import PermissionExtension +from strawberry.file_uploads import Upload # Third-party imports from typing import Optional @@ -262,7 +263,28 @@ class IssueUserPropertyMutation: @strawberry.type class IssueAttachmentMutation: - @strawberry.field( + # @strawberry.field( + # extensions=[PermissionExtension(permissions=[ProjectBasePermission()])] + # ) + # async def create_issue_attachment( + # ) -> IssueAttachment: + # pass + + + # @strawberry.mutation( + # extensions=[PermissionExtension(permissions=[ProjectBasePermission()])] + # ) + # def upload_file(self, file: Upload, info: Info) -> bool: + # content = file.read() + # filename = file.filename + + # # Save the file using Django's file storage + # # file_name = default_storage.save(filename, content) + + # return True + + + @strawberry.mutation( extensions=[PermissionExtension(permissions=[ProjectBasePermission()])] ) async def deleteIssueAttachment( diff --git a/apiserver/plane/graphql/mutations/module.py b/apiserver/plane/graphql/mutations/module.py new file mode 100644 index 0000000000..6548be37f7 --- /dev/null +++ b/apiserver/plane/graphql/mutations/module.py @@ -0,0 +1,117 @@ +# Python imports +import json + +# Strawberry imports +import strawberry +from strawberry.scalars import JSON +from strawberry.types import Info +from strawberry.permission import PermissionExtension + +# Third-party imports +from asgiref.sync import sync_to_async + +# Django imports +from django.utils import timezone + +# Module imports +from plane.graphql.permissions.project import ( + ProjectMemberPermission, +) +from plane.db.models import ( + Project, + ModuleIssue, +) +from plane.graphql.bgtasks.issue_activity_task import issue_activity + + +@strawberry.type +class ModuleIssueMutation: + @strawberry.mutation( + extensions=[ + PermissionExtension(permissions=[ProjectMemberPermission()]) + ] + ) + async def create_module_issues( + self, + info: Info, + slug: str, + project: strawberry.ID, + module: strawberry.ID, + issues: JSON, + ) -> bool: + + project = await sync_to_async(Project.objects.get)(pk=project) + print("issue", issues) + # Create ModuleIssues asynchronously + await sync_to_async( + lambda: ModuleIssue.objects.bulk_create( + [ + ModuleIssue( + issue_id=issue_id, + module_id=module, + project_id=project.id, + workspace_id=project.workspace_id, + created_by_id=info.context.user.id, + updated_by_id=info.context.user.id, + ) + for issue_id in issues + ], + batch_size=10, + ignore_conflicts=True, + ) + )() + + await sync_to_async( + lambda: [ + issue_activity.delay( + type="module.activity.created", + requested_data=json.dumps({"module_id": str(module)}), + actor_id=str(info.context.user.id), + issue_id=str(issue), + project_id=str(project.id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=info.context.request.META.get("HTTP_ORIGIN"), + ) + for issue in issues + ] + )() + + return True + + @strawberry.mutation( + extensions=[ + PermissionExtension(permissions=[ProjectMemberPermission()]) + ] + ) + async def delete_module_issue( + self, + info: Info, + slug: str, + project: strawberry.ID, + module: strawberry.ID, + issue: strawberry.ID, + ) -> bool: + module_issue = await sync_to_async(ModuleIssue.objects.filter)( + module_id=module, + project_id=project, + workspace__slug=slug, + issue_id=issue, + ) + await sync_to_async(issue_activity.delay)( + requested_data=json.dumps({"module_id": str(module)}), + actor_id=str(info.context.user.id), + issue_id=str(issue), + project_id=str(project), + current_instance=None, + type="module.activity.deleted", + epoch=int(timezone.now().timestamp()), + notification=True, + origin=info.context.request.META.get("HTTP_ORIGIN"), + ) + + # Delete the module issue in a synchronous context + await sync_to_async(module_issue.delete)() + + return True diff --git a/apiserver/plane/graphql/queries/cycle.py b/apiserver/plane/graphql/queries/cycle.py new file mode 100644 index 0000000000..6c806eaf0f --- /dev/null +++ b/apiserver/plane/graphql/queries/cycle.py @@ -0,0 +1,82 @@ +# Third-Party Imports +import strawberry + +# Python Standard Library Imports +from asgiref.sync import sync_to_async + +# Strawberry Imports +from strawberry.types import Info +from strawberry.permission import PermissionExtension + +# Module Imports +from plane.db.models import Cycle, Issue +from plane.graphql.types.cycle import CycleType +from plane.graphql.types.issue import IssueType +from plane.graphql.permissions.project import ProjectBasePermission + + +@strawberry.type +class CycleQuery: + @strawberry.field( + extensions=[PermissionExtension(permissions=[ProjectBasePermission()])] + ) + async def cycles( + self, + info: Info, + slug: str, + project: strawberry.ID, + ) -> list[CycleType]: + + cycles = await sync_to_async(list)( + Cycle.objects.filter(workspace__slug=slug) + .filter(project_id=project) + .filter( + project__project_projectmember__member=info.context.user, + project__project_projectmember__is_active=True, + ) + ) + return cycles + + @strawberry.field( + extensions=[PermissionExtension(permissions=[ProjectBasePermission()])] + ) + async def cycle( + self, + info: Info, + slug: str, + project: strawberry.ID, + cycle: strawberry.ID, + ) -> CycleType: + cycle = await sync_to_async(Cycle.objects.get)( + workspace__slug=slug, + project_id=project, + id=cycle, + project__project_projectmember__member=info.context.user, + project__project_projectmember__is_active=True, + ) + return cycle + + +@strawberry.type +class CycleIssueQuery: + @strawberry.field( + extensions=[PermissionExtension(permissions=[ProjectBasePermission()])] + ) + async def cycleIssues( + self, + info: Info, + slug: str, + project: strawberry.ID, + cycle: strawberry.ID, + ) -> list[IssueType]: + + cycles_issues = await sync_to_async(list)( + Issue.issue_objects.filter(workspace__slug=slug) + .filter(project_id=project) + .filter(issue_cycle__cycle_id=cycle) + .filter( + project__project_projectmember__member=info.context.user, + project__project_projectmember__is_active=True, + ) + ) + return cycles_issues diff --git a/apiserver/plane/graphql/queries/issue.py b/apiserver/plane/graphql/queries/issue.py index 86db908463..1b0eb18794 100644 --- a/apiserver/plane/graphql/queries/issue.py +++ b/apiserver/plane/graphql/queries/issue.py @@ -45,9 +45,16 @@ class IssueQuery: filters: Optional[JSON] = {}, orderBy: Optional[str] = "-created_at", groupBy: Optional[str] = None, + type: Optional[str] = "all", ) -> list[IssueType]: + filters = issue_filters(filters, "POST") + if type == "backlog": + filters["state__group__in"] = ["backlog"] + elif type == "active": + filters["state__group__in"] = ["unstarted", "started"] + issues = await sync_to_async(list)( Issue.issue_objects.filter( workspace__slug=slug, project_id=project @@ -114,7 +121,9 @@ class RecentIssuesQuery: ).filter( project__project_projectmember__member=info.context.user, project__project_projectmember__is_active=True, - )[:5] + )[ + :5 + ] ) return issues diff --git a/apiserver/plane/graphql/queries/module.py b/apiserver/plane/graphql/queries/module.py new file mode 100644 index 0000000000..7b68a12546 --- /dev/null +++ b/apiserver/plane/graphql/queries/module.py @@ -0,0 +1,82 @@ +# Third-Party Imports +import strawberry + +# Python Standard Library Imports +from asgiref.sync import sync_to_async + +# Strawberry Imports +from strawberry.types import Info +from strawberry.permission import PermissionExtension + +# Module Imports +from plane.db.models import Module, Issue +from plane.graphql.types.module import ModuleType +from plane.graphql.types.issue import IssueType +from plane.graphql.permissions.project import ProjectBasePermission + + +@strawberry.type +class ModuleQuery: + @strawberry.field( + extensions=[PermissionExtension(permissions=[ProjectBasePermission()])] + ) + async def modules( + self, + info: Info, + slug: str, + project: strawberry.ID, + ) -> list[ModuleType]: + + modules = await sync_to_async(list)( + Module.objects.filter(workspace__slug=slug) + .filter(project_id=project) + .filter( + project__project_projectmember__member=info.context.user, + project__project_projectmember__is_active=True, + ) + ) + return modules + + @strawberry.field( + extensions=[PermissionExtension(permissions=[ProjectBasePermission()])] + ) + async def module( + self, + info: Info, + slug: str, + project: strawberry.ID, + module: strawberry.ID, + ) -> ModuleType: + module = await sync_to_async(Module.objects.get)( + workspace__slug=slug, + project_id=project, + id=module, + project__project_projectmember__member=info.context.user, + project__project_projectmember__is_active=True, + ) + return module + + +@strawberry.type +class ModuleIssueQuery: + @strawberry.field( + extensions=[PermissionExtension(permissions=[ProjectBasePermission()])] + ) + async def moduleIssues( + self, + info: Info, + slug: str, + project: strawberry.ID, + module: strawberry.ID, + ) -> list[IssueType]: + + module_issues = await sync_to_async(list)( + Issue.issue_objects.filter(workspace__slug=slug) + .filter(project_id=project) + .filter(issue_module__module_id=module) + .filter( + project__project_projectmember__member=info.context.user, + project__project_projectmember__is_active=True, + ) + ) + return module_issues diff --git a/apiserver/plane/graphql/schema.py b/apiserver/plane/graphql/schema.py index f725cb74af..95edd12e24 100644 --- a/apiserver/plane/graphql/schema.py +++ b/apiserver/plane/graphql/schema.py @@ -21,6 +21,8 @@ from .queries.issue import ( IssueCommentActivityQuery, ) from .queries.page import PageQuery +from .queries.cycle import CycleQuery, CycleIssueQuery +from .queries.module import ModuleQuery, ModuleIssueQuery from .queries.search import ProjectSearchQuery from .queries.attachment import IssueAttachmentQuery @@ -39,7 +41,8 @@ from .mutations.issue import ( from .mutations.notification import NotificationMutation from .mutations.user import ProfileMutation from .mutations.page import PageFavoriteMutation - +from .mutations.cycle import CycleIssueMutation +from .mutations.module import ModuleIssueMutation # combined query class for all @strawberry.type @@ -64,6 +67,10 @@ class Query( WorkspaceStateQuery, ProjectSearchQuery, IssueAttachmentQuery, + CycleQuery, + CycleIssueQuery, + ModuleQuery, + ModuleIssueQuery, ): pass @@ -82,6 +89,8 @@ class Mutation( ProfileMutation, PageFavoriteMutation, IssueAttachmentMutation, + CycleIssueMutation, + ModuleIssueMutation, ): pass diff --git a/apiserver/plane/graphql/types/cycle.py b/apiserver/plane/graphql/types/cycle.py new file mode 100644 index 0000000000..59b08ec889 --- /dev/null +++ b/apiserver/plane/graphql/types/cycle.py @@ -0,0 +1,76 @@ +# python imports +from typing import Optional +from datetime import date, datetime + +# Strawberry imports +import strawberry +import strawberry_django +from strawberry.types import Info +from strawberry.scalars import JSON + +# Third-party library imports +from asgiref.sync import sync_to_async + + +# Module Imports +from plane.db.models import Cycle, Issue + + +@strawberry_django.type(Cycle) +class CycleType: + id: strawberry.ID + name: str + description: Optional[str] + start_date: Optional[date] + end_date: Optional[date] + owned_by: strawberry.ID + view_props: Optional[JSON] + sort_order: Optional[float] + external_source: Optional[str] + external_id: Optional[strawberry.ID] + progress_snapshot: Optional[JSON] + archived_at: Optional[datetime] + logo_props: Optional[JSON] + project: strawberry.ID + workspace: strawberry.ID + created_by: strawberry.ID + updated_by: strawberry.ID + created_at: datetime + updated_at: datetime + total_issues: int + completed_issues: int + + @strawberry.field + def owned_by(self) -> int: + return self.owned_by_id + + @strawberry.field + def project(self) -> int: + return self.project_id + + @strawberry.field + def workspace(self) -> int: + return self.workspace_id + + @strawberry.field + def created_by(self) -> int: + return self.created_by_id + + @strawberry.field + async def total_issues(self, info: Info) -> int: + total_issues = await sync_to_async( + lambda: Issue.issue_objects.filter( + issue_cycle__cycle_id=self.id + ).count() + )() + return total_issues + + @strawberry.field + async def completed_issues(self, info: Info) -> int: + total_issues = await sync_to_async( + lambda: Issue.issue_objects.filter( + issue_cycle__cycle_id=self.id, + state__group="completed" + ).count() + )() + return total_issues diff --git a/apiserver/plane/graphql/types/issue.py b/apiserver/plane/graphql/types/issue.py index 2c2496324c..740916b71e 100644 --- a/apiserver/plane/graphql/types/issue.py +++ b/apiserver/plane/graphql/types/issue.py @@ -9,6 +9,8 @@ from asgiref.sync import sync_to_async import strawberry import strawberry_django from strawberry.scalars import JSON +from strawberry.types import Info + # Module Imports from plane.db.models import ( @@ -16,6 +18,8 @@ from plane.db.models import ( IssueUserProperty, IssueActivity, IssueComment, + CycleIssue, + ModuleIssue, ) @@ -48,6 +52,8 @@ class IssueType: created_at: datetime updated_at: datetime sequence_id: int + cycle: Optional[strawberry.ID] + modules: Optional[list[strawberry.ID]] @strawberry.field def state(self) -> int: @@ -83,13 +89,28 @@ class IssueType: labels = await sync_to_async(list)(self.labels.all()) return [label.id for label in labels] - # @strawberry.field - # async def attachments(self) -> list[JSON]: - # return await sync_to_async(list) list( - # IssueAttachment.objects.filter(issue_id=self.id).values( - # "id", "attributes", "asset" - # ) - # ) + @strawberry.field + async def cycle(self, info: Info) -> strawberry.ID: + cycle_issue = await sync_to_async( + CycleIssue.objects.filter(issue_id=self.id).first + )() + if cycle_issue: + return str(cycle_issue.cycle_id) + return None + + @strawberry.field + async def modules(self, info: Info) -> list[strawberry.ID]: + # Fetch related module IDs in a synchronous context + module_issues = await sync_to_async( + lambda: list( + ModuleIssue.objects.filter(issue_id=self.id).values_list( + "module_id", flat=True + ) + ) + )() + + # Return the module IDs as strings + return [str(module_id) for module_id in module_issues] @strawberry_django.type(IssueUserProperty) diff --git a/apiserver/plane/graphql/types/module.py b/apiserver/plane/graphql/types/module.py new file mode 100644 index 0000000000..141da5a496 --- /dev/null +++ b/apiserver/plane/graphql/types/module.py @@ -0,0 +1,72 @@ +# python imports +from typing import Optional +from datetime import date, datetime + +# Strawberry imports +import strawberry +import strawberry_django +from strawberry.types import Info +from strawberry.scalars import JSON + +# Module Imports +from plane.db.models import Module, Issue + +# Third-party library imports +from asgiref.sync import sync_to_async + +@strawberry_django.type(Module) +class ModuleType: + name: str + status: str + id: strawberry.ID + project: strawberry.ID + workspace: strawberry.ID + created_by: strawberry.ID + updated_by: strawberry.ID + created_at: datetime + updated_at: datetime + description: Optional[str] + description_text: Optional[str] + description_html: Optional[str] + start_date: Optional[date] + target_date: Optional[date] + lead: Optional[strawberry.ID] + members: Optional[list[strawberry.ID]] + view_props: Optional[JSON] + sort_order: float + external_source: Optional[str] + external_id: Optional[strawberry.ID] + archived_at: Optional[datetime] + logo_props: Optional[JSON] + total_issues: int + completed_issues: int + + @strawberry.field + def project(self) -> int: + return self.project_id + + @strawberry.field + def workspace(self) -> int: + return self.workspace_id + + @strawberry.field + def created_by(self) -> int: + return self.created_by_id + + @strawberry.field + async def total_issues(self, info: Info) -> int: + total_issues = await sync_to_async( + lambda: Issue.issue_objects.filter( + issue_module__module_id=self.id + ).count() + )() + return total_issues + + @strawberry.field + async def completed_issues(self, info: Info) -> int: + total_issues = await sync_to_async( + lambda: Issue.issue_objects.filter( + issue_module__module_id=self.id, state__group="completed" + ).count() + )() + return total_issues diff --git a/apiserver/plane/graphql/types/project.py b/apiserver/plane/graphql/types/project.py index a24803ec90..5e74d89804 100644 --- a/apiserver/plane/graphql/types/project.py +++ b/apiserver/plane/graphql/types/project.py @@ -5,10 +5,14 @@ from datetime import datetime # Strawberry imports import strawberry import strawberry_django +from strawberry.types import Info from strawberry.scalars import JSON # Module imports -from plane.db.models import Project, ProjectMember +from plane.db.models import Project, ProjectMember, Issue + +# Third-party library imports +from asgiref.sync import sync_to_async @strawberry_django.type(Project) @@ -37,6 +41,8 @@ class ProjectType: archived_at: Optional[datetime] is_member: bool is_favorite: bool + total_members: int + total_issues: int @strawberry.field def workspace(self) -> int: @@ -58,6 +64,22 @@ class ProjectType: def default_state(self) -> int: return self.default_state_id + @strawberry.field + async def total_members(self, info: strawberry.Info) -> int: + projects = await sync_to_async( + lambda: ProjectMember.objects.filter( + project_id=self.id, is_active=True + ).count() + )() + return projects + + @strawberry.field + async def total_issues(self, info: Info) -> int: + projects = await sync_to_async( + lambda: Issue.issue_objects.filter(project_id=self.id).count() + )() + return projects + @strawberry_django.type(ProjectMember) class ProjectMemberType: