diff --git a/apiserver/plane/api/views/intake.py b/apiserver/plane/api/views/intake.py index 4f347c46b4..474aed01be 100644 --- a/apiserver/plane/api/views/intake.py +++ b/apiserver/plane/api/views/intake.py @@ -2,12 +2,12 @@ import json # Django imports -from django.core.serializers.json import DjangoJSONEncoder -from django.utils import timezone -from django.db.models import Q, Value, UUIDField -from django.db.models.functions import Coalesce from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField +from django.core.serializers.json import DjangoJSONEncoder +from django.db.models import Q, UUIDField, Value +from django.db.models.functions import Coalesce +from django.utils import timezone # Third party imports from rest_framework import status @@ -28,6 +28,7 @@ from plane.db.models import ( ) from plane.utils.host import base_host from plane.ee.models import IntakeSetting +from plane.ee.utils.workflow import WorkflowStateManager from .base import BaseAPIView @@ -237,6 +238,23 @@ class IntakeIssueAPIEndpoint(BaseAPIView): Value([], output_field=ArrayField(UUIDField())), ), ).get(pk=issue_id, workspace__slug=slug, project_id=project_id) + + # Check if state is updated then is the transition allowed + workflow_state_manager = WorkflowStateManager( + project_id=project_id, slug=slug + ) + if request.data.get( + "state_id" + ) and not workflow_state_manager.validate_state_transition( + issue=issue, + new_state_id=request.data.get("state_id"), + user_id=request.user.id, + ): + return Response( + {"error": "State transition is not allowed"}, + status=status.HTTP_403_FORBIDDEN, + ) + # Only allow guests to edit name and description if project_member.role <= 5: issue_data = { diff --git a/apiserver/plane/graphql/mutations/issue.py b/apiserver/plane/graphql/mutations/issue.py index 6a9444f731..95a4192396 100644 --- a/apiserver/plane/graphql/mutations/issue.py +++ b/apiserver/plane/graphql/mutations/issue.py @@ -11,6 +11,7 @@ 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 typing import Optional @@ -38,6 +39,43 @@ from plane.db.models import ( ) from plane.graphql.bgtasks.issue_activity_task import issue_activity from plane.graphql.utils.issue_activity import convert_issue_properties_to_activity_dict +from plane.graphql.utils.workflow import WorkflowStateManager + + +@sync_to_async +def validate_workflow_state_issue_create(user_id, slug, project_id, state_id): + workflow_manager = WorkflowStateManager( + user_id=user_id, slug=slug, project_id=project_id + ) + is_issue_creation_allowed = workflow_manager._validate_issue_creation( + state_id=state_id + ) + + if is_issue_creation_allowed is False: + message = "You cannot create an issue in this state" + error_extensions = {"code": "FORBIDDEN", "statusCode": 403} + raise GraphQLError(message, extensions=error_extensions) + + return is_issue_creation_allowed + + +@sync_to_async +def validate_workflow_state_issue_update( + user_id, slug, project_id, current_state_id, new_state_id +): + workflow_state_manager = WorkflowStateManager( + user_id=user_id, slug=slug, project_id=project_id + ) + can_state_update = workflow_state_manager._validate_state_transition( + current_state_id=current_state_id, new_state_id=new_state_id + ) + + if can_state_update is False: + message = "You cannot update an issue in this state" + error_extensions = {"code": "FORBIDDEN", "statusCode": 403} + raise GraphQLError(message, extensions=error_extensions) + + return can_state_update @strawberry.type @@ -67,8 +105,20 @@ class IssueMutation: workspace = await sync_to_async(Workspace.objects.get)(slug=slug) project_details = await sync_to_async(Project.objects.get)(id=project) - issue_type_id = None + if state is not None: + is_feature_flagged = await validate_feature_flag( + user_id=str(user.id), + slug=slug, + feature_key=FeatureFlagsTypesEnum.WORKFLOWS.value, + default_value=False, + ) + if is_feature_flagged: + await validate_workflow_state_issue_create( + user_id=user.id, slug=slug, project_id=project, state_id=state + ) + # validating issue type and assigning thr default issue type + issue_type_id = None is_feature_flagged = await validate_feature_flag( slug=workspace.slug, user_id=str(user.id), @@ -263,8 +313,26 @@ class IssueMutation: startDate: Optional[datetime] = None, targetDate: Optional[datetime] = None, ) -> IssuesType: + user = info.context.user issue = await sync_to_async(Issue.objects.get)(id=id) + issue_state_id = issue.state_id + if state and str(issue_state_id) != str(state): + is_feature_flagged = await validate_feature_flag( + user_id=str(user.id), + slug=slug, + feature_key=FeatureFlagsTypesEnum.WORKFLOWS.value, + default_value=False, + ) + if is_feature_flagged: + await validate_workflow_state_issue_update( + user_id=user.id, + slug=slug, + project_id=project, + current_state_id=issue.state_id, + new_state_id=state, + ) + # activity tacking data current_issue_activity = await convert_issue_properties_to_activity_dict(issue) activity_payload = {} diff --git a/apiserver/plane/graphql/mutations/issues/__init__.py b/apiserver/plane/graphql/mutations/issues/__init__.py index c2e70c6904..780e90778b 100644 --- a/apiserver/plane/graphql/mutations/issues/__init__.py +++ b/apiserver/plane/graphql/mutations/issues/__init__.py @@ -1,7 +1,8 @@ -from .relation import IssueRelationMutation -from .comment import IssueCommentMutation -from .sub_issue import SubIssueMutation -from .cycle import IssueCycleMutation -from .module import IssueModuleMutation -from .links import IssueLinkMutation from .attachment import IssueAttachmentMutation +from .base import IssueMutationV2 +from .comment import IssueCommentMutation +from .cycle import IssueCycleMutation +from .links import IssueLinkMutation +from .module import IssueModuleMutation +from .relation import IssueRelationMutation +from .sub_issue import SubIssueMutation diff --git a/apiserver/plane/graphql/mutations/issues/base.py b/apiserver/plane/graphql/mutations/issues/base.py new file mode 100644 index 0000000000..f80bfe965a --- /dev/null +++ b/apiserver/plane/graphql/mutations/issues/base.py @@ -0,0 +1,487 @@ +# Python imports +import json + +# Django imports +from django.utils import timezone +from django.core import serializers + +# Strawberry imports +import strawberry +from strawberry.types import Info +from strawberry.permission import PermissionExtension +from strawberry.exceptions import GraphQLError + +# Third-party imports +from typing import Optional +from asgiref.sync import sync_to_async + +# Module imports +from plane.graphql.utils.feature_flag import validate_feature_flag +from plane.graphql.types.issue import ( + IssueCreateInputType, + IssueUpdateInputType, + IssuesType, +) +from plane.graphql.types.feature_flag import FeatureFlagsTypesEnum +from plane.graphql.permissions.project import ProjectMemberPermission +from plane.db.models import ( + Project, + Issue, + IssueAssignee, + IssueLabel, + Workspace, + IssueType, + CycleIssue, + ModuleIssue, + State, +) +from plane.graphql.bgtasks.issue_activity_task import issue_activity +from plane.graphql.utils.issue_activity import convert_issue_properties_to_activity_dict +from plane.graphql.utils.workflow import WorkflowStateManager + + +@sync_to_async +def get_workspace(slug): + try: + return Workspace.objects.get(slug=slug) + except Workspace.DoesNotExist: + message = "Workspace not found" + error_extensions = {"code": "NOT_FOUND", "statusCode": 404} + raise GraphQLError(message, extensions=error_extensions) + + +@sync_to_async +def get_project(project_id): + try: + return Project.objects.get(id=project_id) + except Project.DoesNotExist: + message = "Project not found" + error_extensions = {"code": "NOT_FOUND", "statusCode": 404} + raise GraphQLError(message, extensions=error_extensions) + + +@sync_to_async +def get_project_default_state(project_id): + try: + return State.objects.get(project_id=project_id, default=True) + except State.DoesNotExist: + message = "Default state not found" + error_extensions = {"code": "NOT_FOUND", "statusCode": 404} + raise GraphQLError(message, extensions=error_extensions) + + +@sync_to_async +def validate_workflow_state_issue_create(user_id, slug, project_id, state_id): + workflow_manager = WorkflowStateManager( + user_id=user_id, slug=slug, project_id=project_id + ) + is_issue_creation_allowed = workflow_manager._validate_issue_creation( + state_id=state_id + ) + + if is_issue_creation_allowed is False: + message = "You cannot create an issue in this state" + error_extensions = {"code": "FORBIDDEN", "statusCode": 403} + raise GraphQLError(message, extensions=error_extensions) + + return is_issue_creation_allowed + + +@sync_to_async +def validate_workflow_state_issue_update( + user_id, slug, project_id, current_state_id, new_state_id +): + workflow_state_manager = WorkflowStateManager( + user_id=user_id, slug=slug, project_id=project_id + ) + can_state_update = workflow_state_manager._validate_state_transition( + current_state_id=current_state_id, new_state_id=new_state_id + ) + + if can_state_update is False: + message = "You cannot update an issue in this state" + error_extensions = {"code": "FORBIDDEN", "statusCode": 403} + raise GraphQLError(message, extensions=error_extensions) + + return can_state_update + + +@strawberry.type +class IssueMutationV2: + @strawberry.mutation( + extensions=[PermissionExtension(permissions=[ProjectMemberPermission()])] + ) + async def create_issue_v2( + self, info: Info, slug: str, project: str, issue_input: IssueCreateInputType + ) -> IssuesType: + user = info.context.user + user_id = str(user.id) + + workspace = await get_workspace(slug) + workspace_slug = workspace.slug + workspace_id = str(workspace.id) + + project_details = await get_project(project) + project_id = str(project_details.id) + + issue_state_id = issue_input.state or None + issue_labels = issue_input.labels or None + issue_assignees = issue_input.assignees or None + issue_cycle_id = issue_input.cycle_id or None + issue_module_ids = issue_input.module_ids or None + + issue_payload = { + k: v + for k, v in { + "name": issue_input.name, + "description_html": issue_input.description_html, + "priority": issue_input.priority, + "start_date": issue_input.start_date, + "target_date": issue_input.target_date, + "state_id": issue_input.state, + "parent_id": issue_input.parent, + "estimate_point_id": issue_input.estimate_point, + }.items() + if v is not None + } + + # if the state id is not passed, get the default state + if issue_state_id is None: + state = await get_project_default_state(project_id) + issue_state_id = str(state.id) + issue_payload["state_id"] = issue_state_id + + # validate the workflow if the project has workflows enabled + if issue_state_id is not None: + is_feature_flagged = await validate_feature_flag( + user_id=user_id, + slug=workspace_slug, + feature_key=FeatureFlagsTypesEnum.WORKFLOWS.value, + default_value=False, + ) + if is_feature_flagged: + await validate_workflow_state_issue_create( + user_id=user_id, + slug=workspace_slug, + project_id=project_id, + state_id=issue_state_id, + ) + + # validate the issue type if the project has issue types enabled + issue_type_id = None + is_feature_flagged = await validate_feature_flag( + slug=workspace_slug, + user_id=user_id, + feature_key=FeatureFlagsTypesEnum.ISSUE_TYPES.value, + default_value=False, + ) + if is_feature_flagged: + try: + issue_type = await sync_to_async(IssueType.objects.get)( + workspace_id=workspace_id, + project_issue_types__project_id=project_id, + is_default=True, + ) + if issue_type is not None: + issue_type_id = issue_type.id + except IssueType.DoesNotExist: + pass + + # create the issue + issue = await sync_to_async(Issue.objects.create)( + workspace_id=workspace_id, + project_id=project_id, + type_id=issue_type_id, + **issue_payload, + ) + issue_id = str(issue.id) + + # updating the assignees + if issue_assignees is not None and len(issue_assignees) > 0: + await sync_to_async(IssueAssignee.objects.bulk_create)( + [ + IssueAssignee( + workspace_id=workspace_id, + project_id=project_id, + issue_id=issue_id, + assignee_id=assignee, + created_by_id=user_id, + updated_by_id=user_id, + ) + for assignee in issue_assignees + ], + batch_size=10, + ) + + # updating the labels + if issue_labels is not None and len(issue_labels) > 0: + await sync_to_async(IssueLabel.objects.bulk_create)( + [ + IssueLabel( + workspace_id=workspace_id, + project_id=project_id, + issue_id=issue_id, + label_id=label, + created_by_id=user_id, + updated_by_id=user_id, + ) + for label in issue_labels + ], + batch_size=10, + ) + + # activity tacking data + activity_payload = {} + for key, value in issue_payload.items(): + if key == "estimate_point_id": + activity_payload["estimate_point"] = value + elif key in ("start_date", "target_date") and value is not None: + activity_payload["start_date"] = value.strftime("%Y-%m-%d") + else: + activity_payload[key] = value + if issue_labels is not None and len(issue_labels) > 0: + activity_payload["label_ids"] = issue_labels + if issue_assignees is not None and len(issue_assignees) > 0: + activity_payload["assignee_ids"] = issue_assignees + + # Track the issue + await sync_to_async(issue_activity.delay)( + type="issue.activity.created", + origin=info.context.request.META.get("HTTP_ORIGIN"), + epoch=int(timezone.now().timestamp()), + notification=True, + project_id=str(project), + issue_id=str(issue.id), + actor_id=str(user.id), + current_instance=None, + requested_data=json.dumps(activity_payload), + ) + + # creating the cycle and cycle activity with the cycle id + if issue_cycle_id is not None: + created_cycle = await sync_to_async(CycleIssue.objects.create)( + workspace_id=workspace_id, + project_id=project_id, + issue_id=issue_id, + cycle_id=issue_cycle_id, + created_by_id=user_id, + updated_by_id=user_id, + ) + + issue_activity.delay( + type="cycle.activity.created", + requested_data=json.dumps({"cycles_list": list(str(issue_id))}), + actor_id=str(user.id), + issue_id=None, + project_id=str(project_id), + current_instance=json.dumps( + { + "updated_cycle_issues": [], + "created_cycle_issues": serializers.serialize( + "json", [created_cycle] + ), + } + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=info.context.request.META.get("HTTP_ORIGIN"), + ) + + # creating the modules and module activity with the module ids + if issue_module_ids and len(issue_module_ids) > 0: + await sync_to_async( + lambda: ModuleIssue.objects.bulk_create( + [ + ModuleIssue( + workspace_id=workspace_id, + project_id=project_id, + issue_id=issue_id, + module_id=module_id, + created_by_id=user_id, + updated_by_id=user_id, + ) + for module_id in issue_module_ids + ], + 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_id)}), + actor_id=str(user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=info.context.request.META.get("HTTP_ORIGIN"), + ) + for module_id in issue_module_ids + ] + )() + + return issue + + @strawberry.mutation( + extensions=[PermissionExtension(permissions=[ProjectMemberPermission()])] + ) + async def update_issue_v2( + self, + info: Info, + slug: str, + project: str, + id: str, + issue_input: Optional[IssueUpdateInputType] = None, + ) -> IssuesType: + user = info.context.user + user_id = str(user.id) + + workspace = await get_workspace(slug) + workspace_id = str(workspace.id) + + project_details = await get_project(project) + project_id = str(project_details.id) + + provided_fields = { + k: v + for k, v in info.variable_values.get("issueInput", {}).items() + if k in info.variable_values.get("issueInput", {}) + } + + # get the issue + issue = await sync_to_async(Issue.objects.get)(id=id) + + # get the current state id + current_state_id = str(issue.state_id) + + # get the current issue activity + current_issue_activity = await convert_issue_properties_to_activity_dict(issue) + + # activity tacking data + activity_payload = {} + + if "name" in provided_fields and issue_input.name is not None: + issue.name = provided_fields["name"] + activity_payload["name"] = provided_fields["name"] + + if "description_html" in provided_fields: + issue.description_html = provided_fields["description_html"] + activity_payload["description_html"] = provided_fields["description_html"] + + if "priority" in provided_fields: + issue.priority = provided_fields["priority"] + activity_payload["priority"] = provided_fields["priority"] + + if "startDate" in provided_fields: + if issue_input.start_date is not None: + issue.start_date = issue_input.start_date + activity_payload["start_date"] = issue_input.start_date.strftime( + "%Y-%m-%d" + ) + else: + issue.start_date = None + activity_payload["start_date"] = None + if "targetDate" in provided_fields: + if issue_input.target_date is not None: + issue.target_date = issue_input.target_date + activity_payload["target_date"] = issue_input.target_date.strftime( + "%Y-%m-%d" + ) + else: + issue.target_date = None + activity_payload["target_date"] = None + + if "state" in provided_fields: + issue.state_id = provided_fields["state"] + activity_payload["state_id"] = provided_fields["state"] + + if "parent" in provided_fields: + issue.parent_id = provided_fields["parent"] + activity_payload["parent_id"] = provided_fields["parent"] + + if "estimate_point" in provided_fields: + issue.estimate_point_id = provided_fields["estimate_point"] + activity_payload["estimate_point"] = provided_fields["estimate_point"] + + # validate the workflow if the project has workflows enabled + state_id = provided_fields["state"] if "state" in provided_fields else None + if state_id: + is_feature_flagged = await validate_feature_flag( + user_id=str(user.id), + slug=slug, + feature_key=FeatureFlagsTypesEnum.WORKFLOWS.value, + default_value=False, + ) + if is_feature_flagged: + await validate_workflow_state_issue_update( + user_id=user.id, + slug=slug, + project_id=project, + current_state_id=current_state_id, + new_state_id=state_id, + ) + + # updating the issue + await sync_to_async(issue.save)() + issue_id = str(issue.id) + + # creating or updating the assignees + assignees = ( + provided_fields["assignees"] if "assignees" in provided_fields else None + ) + if assignees: + await sync_to_async(IssueAssignee.objects.filter(issue=issue).delete)() + if len(assignees) > 0: + await sync_to_async(IssueAssignee.objects.bulk_create)( + [ + IssueAssignee( + workspace_id=workspace_id, + project_id=project_id, + issue_id=issue_id, + assignee_id=assignee, + created_by_id=user_id, + updated_by_id=user_id, + ) + for assignee in assignees + ], + batch_size=10, + ) + + # creating or updating the labels + labels = provided_fields["labels"] if "labels" in provided_fields else None + if labels: + await sync_to_async(IssueLabel.objects.filter(issue=issue).delete)() + if len(labels) > 0: + await sync_to_async(IssueLabel.objects.bulk_create)( + [ + IssueLabel( + workspace_id=workspace_id, + project_id=project_id, + issue_id=issue_id, + label_id=label, + created_by_id=user_id, + updated_by_id=user_id, + ) + for label in labels + ], + batch_size=10, + ) + + # Track the issue + issue_activity.delay( + type="issue.activity.updated", + origin=info.context.request.META.get("HTTP_ORIGIN"), + epoch=int(timezone.now().timestamp()), + notification=True, + project_id=str(project), + issue_id=str(issue.id), + actor_id=str(info.context.user.id), + current_instance=json.dumps(current_issue_activity), + requested_data=json.dumps(activity_payload), + ) + + return issue diff --git a/apiserver/plane/graphql/mutations/page.py b/apiserver/plane/graphql/mutations/page.py index 9756451c07..4ae24da9b5 100644 --- a/apiserver/plane/graphql/mutations/page.py +++ b/apiserver/plane/graphql/mutations/page.py @@ -26,6 +26,13 @@ from plane.db.models import ( Project, ) +@strawberry.input +class PageInput: + name: str + description_html: Optional[str] = strawberry.field(default="
") + logo_props: Optional[JSON] = strawberry.field(default_factory=dict) + access: int = strawberry.field(default=2) + description_binary: Optional[str] = strawberry.field(default=None) @sync_to_async def get_workspace_member(slug: str, user_id: str): @@ -125,6 +132,56 @@ class PageMutation: return page_details + @strawberry.mutation( + extensions=[ + PermissionExtension( + permissions=[ProjectPermission([Roles.ADMIN, Roles.MEMBER])] + ) + ] + ) + async def batchCreatePages( + self, + info: Info, + slug: str, + project: strawberry.ID, + pages: list[PageInput], + ) -> None: + workspace = await sync_to_async(Workspace.objects.get)(slug=slug) + project_details = await sync_to_async(Project.objects.get)(id=project) + + # Prepare pages for bulk creation + pages_to_create = [ + Page( + workspace=workspace, + name=page_data.name, + description_html=page_data.description_html, + description_binary=base64.b64decode(page_data.description_binary) if page_data.description_binary else None, + logo_props=page_data.logo_props, + access=page_data.access, + owned_by=info.context.user, + ) + for page_data in pages + ] + + # Bulk create pages + created_pages = await sync_to_async(Page.objects.bulk_create)(pages_to_create) + + # Prepare project pages for bulk creation + project_pages_to_create = [ + ProjectPage( + workspace=workspace, + project=project_details, + page=created_page, + created_by=info.context.user, + updated_by=info.context.user, + ) + for created_page in created_pages + ] + + # Bulk create project pages + await sync_to_async(ProjectPage.objects.bulk_create)(project_pages_to_create) + return None + @strawberry.mutation( extensions=[PermissionExtension(permissions=[ProjectBasePermission()])] ) diff --git a/apiserver/plane/graphql/mutations/stickies.py b/apiserver/plane/graphql/mutations/stickies.py new file mode 100644 index 0000000000..c5df547c18 --- /dev/null +++ b/apiserver/plane/graphql/mutations/stickies.py @@ -0,0 +1,97 @@ +# Python imports +from dataclasses import asdict +from typing import List, Optional + +# Strawberry Imports +import strawberry +from asgiref.sync import sync_to_async +from strawberry.exceptions import GraphQLError +from strawberry.permission import PermissionExtension +from strawberry.types import Info + +# Module Imports +from plane.db.models import Sticky, Workspace +from plane.graphql.permissions.workspace import WorkspacePermission +from plane.graphql.types.stickies import StickiesType, StickyCreateUpdateInputType + + +@sync_to_async +def get_workspace(slug: str, user_id: str) -> Optional[Workspace]: + try: + return Workspace.objects.get(slug=slug) + except Workspace.DoesNotExist: + message = "Workspace not found" + error_extensions = {"code": "WORKSPACE_NOT_FOUND", "statusCode": 404} + raise GraphQLError(message, extensions=error_extensions) + + +@sync_to_async +def get_sticky(slug: str, user_id: str, id: str) -> Optional[Sticky]: + try: + return Sticky.objects.get( + workspace__slug=slug, + workspace__workspace_member__member_id=user_id, + workspace__workspace_member__is_active=True, + owner_id=user_id, + id=id, + ) + except Sticky.DoesNotExist: + message = "Sticky not found" + error_extensions = {"code": "STICKY_NOT_FOUND", "statusCode": 404} + raise GraphQLError(message, extensions=error_extensions) + + +@strawberry.type +class WorkspaceStickiesMutation: + @strawberry.mutation( + extensions=[PermissionExtension(permissions=[WorkspacePermission()])] + ) + async def create_sticky( + self, info: Info, slug: str, sticky_data: StickyCreateUpdateInputType + ) -> StickiesType: + user = info.context.user + user_id = user.id + + workspace = await get_workspace(slug=slug, user_id=user_id) + workspace_id = workspace.id + + sticky = await sync_to_async(Sticky.objects.create)( + workspace_id=workspace_id, owner=user, **asdict(sticky_data) + ) + + return sticky + + @strawberry.mutation( + extensions=[PermissionExtension(permissions=[WorkspacePermission()])] + ) + async def update_sticky( + self, + info: Info, + slug: str, + sticky: strawberry.ID, + sticky_data: StickyCreateUpdateInputType, + ) -> StickiesType: + user = info.context.user + + sticky_instance = await get_sticky(slug=slug, user_id=user.id, id=sticky) + + for key, value in asdict(sticky_data).items(): + setattr(sticky_instance, key, value) + + await sync_to_async(sticky_instance.save)() + + return sticky_instance + + @strawberry.mutation( + extensions=[PermissionExtension(permissions=[WorkspacePermission()])] + ) + async def delete_stickies( + self, info: Info, slug: str, stickies: List[strawberry.ID] + ) -> bool: + user = info.context.user + + for sticky in stickies: + sticky_instance = await get_sticky(slug=slug, user_id=user.id, id=sticky) + await sync_to_async(sticky_instance.delete)() + + return True diff --git a/apiserver/plane/graphql/queries/feature_flag.py b/apiserver/plane/graphql/queries/feature_flag.py index fb571821e1..2728818fed 100644 --- a/apiserver/plane/graphql/queries/feature_flag.py +++ b/apiserver/plane/graphql/queries/feature_flag.py @@ -96,6 +96,9 @@ class FeatureFlagQuery: file_size_limit_pro=feature_flags.get( FeatureFlagsTypesEnum.FILE_SIZE_LIMIT_PRO.value, False ), + timeline_dependency=feature_flags.get( + FeatureFlagsTypesEnum.TIMELINE_DEPENDENCY.value, False + ), teamspaces=feature_flags.get(FeatureFlagsTypesEnum.TEAMSPACES.value, False), inbox_stacking=feature_flags.get( FeatureFlagsTypesEnum.INBOX_STACKING.value, False @@ -112,6 +115,8 @@ class FeatureFlagQuery: home_advanced=feature_flags.get( FeatureFlagsTypesEnum.HOME_ADVANCED.value, False ), + workflows=feature_flags.get(FeatureFlagsTypesEnum.WORKFLOWS.value, False), + customers=feature_flags.get(FeatureFlagsTypesEnum.CUSTOMERS.value, False), intake_settings=feature_flags.get( FeatureFlagsTypesEnum.INTAKE_SETTINGS.value, False ), @@ -124,6 +129,9 @@ class FeatureFlagQuery: silo_importers=feature_flags.get( FeatureFlagsTypesEnum.SILO_IMPORTERS.value, False ), + flatfile_importer=feature_flags.get( + FeatureFlagsTypesEnum.FLATFILE_IMPORTER.value, False + ), jira_importer=feature_flags.get( FeatureFlagsTypesEnum.JIRA_IMPORTER.value, False ), diff --git a/apiserver/plane/graphql/queries/stickies.py b/apiserver/plane/graphql/queries/stickies.py new file mode 100644 index 0000000000..4d81e29069 --- /dev/null +++ b/apiserver/plane/graphql/queries/stickies.py @@ -0,0 +1,68 @@ +# Third-Party Imports +from typing import Optional + +# Strawberry Imports +import strawberry +from asgiref.sync import sync_to_async +from strawberry.exceptions import GraphQLError +from strawberry.permission import PermissionExtension +from strawberry.types import Info + +# Module Imports +from plane.db.models import Sticky +from plane.graphql.permissions.workspace import WorkspacePermission +from plane.graphql.types.stickies import StickiesType +from plane.graphql.utils.paginator import paginate +from plane.graphql.types.paginator import PaginatorResponse + + +@sync_to_async +def get_sticky(slug: str, user_id: str, id: str) -> Optional[Sticky]: + try: + return Sticky.objects.get( + workspace__slug=slug, + workspace__workspace_member__member_id=user_id, + workspace__workspace_member__is_active=True, + owner_id=user_id, + id=id, + deleted_at__isnull=True, + ) + except Sticky.DoesNotExist: + message = "Sticky not found" + error_extensions = {"code": "STICKY_NOT_FOUND", "statusCode": 404} + raise GraphQLError(message, extensions=error_extensions) + + +@strawberry.type +class WorkspaceStickiesQuery: + # Return a list of stickies + @strawberry.field( + extensions=[PermissionExtension(permissions=[WorkspacePermission()])] + ) + async def stickies( + self, info: Info, slug: str, cursor: Optional[str] = None + ) -> PaginatorResponse[StickiesType]: + user = info.context.user + + stickies = await sync_to_async(list)( + Sticky.objects.filter(workspace__slug=slug, owner=user).filter( + workspace__workspace_member__member=user, + workspace__workspace_member__is_active=True, + ) + ) + + return paginate(results_object=stickies, cursor=cursor) + + # Return a single sticky + @strawberry.field( + extensions=[PermissionExtension(permissions=[WorkspacePermission()])] + ) + async def sticky( + self, info: Info, slug: str, sticky: strawberry.ID + ) -> StickiesType: + user = info.context.user + user_id = user.id + + sticky_detail = await get_sticky(slug=slug, user_id=user_id, id=sticky) + + return sticky_detail diff --git a/apiserver/plane/graphql/queries/workspace/base.py b/apiserver/plane/graphql/queries/workspace/base.py index cd71004499..794e220315 100644 --- a/apiserver/plane/graphql/queries/workspace/base.py +++ b/apiserver/plane/graphql/queries/workspace/base.py @@ -154,6 +154,7 @@ class YourWorkQuery: ) ) .values_list("id", flat=True) + .distinct() ) # pages diff --git a/apiserver/plane/graphql/schema.py b/apiserver/plane/graphql/schema.py index 40f2e4765d..e5789b86f9 100644 --- a/apiserver/plane/graphql/schema.py +++ b/apiserver/plane/graphql/schema.py @@ -55,6 +55,7 @@ from .queries.version_check import VersionCheckQuery from .queries.timezone import TimezoneListQuery from .queries.asset import WorkspaceAssetQuery, ProjectAssetQuery from .queries.instance import InstanceQuery +from .queries.stickies import WorkspaceStickiesQuery # mutations from .mutations.workspace import WorkspaceMutation, WorkspaceInviteMutation @@ -91,6 +92,7 @@ from .mutations.issues import ( IssueCycleMutation, IssueLinkMutation, IssueAttachmentMutation, + IssueMutationV2, ) from .mutations.device import DeviceInformationMutation from .mutations.asset import ( @@ -98,6 +100,7 @@ from .mutations.asset import ( WorkspaceAssetMutation, ProjectAssetMutation, ) +from .mutations.stickies import WorkspaceStickiesMutation # combined query class for all @@ -155,6 +158,7 @@ class Query( ProjectAssetQuery, InstanceQuery, IssueShortenedMetaInfoQuery, + WorkspaceStickiesQuery, ): pass @@ -195,6 +199,8 @@ class Mutation( WorkspaceAssetMutation, ProjectAssetMutation, IssueLinkMutation, + WorkspaceStickiesMutation, + IssueMutationV2, ): pass diff --git a/apiserver/plane/graphql/types/feature_flag.py b/apiserver/plane/graphql/types/feature_flag.py index 7a76e5414e..bba2046cac 100644 --- a/apiserver/plane/graphql/types/feature_flag.py +++ b/apiserver/plane/graphql/types/feature_flag.py @@ -28,20 +28,24 @@ class FeatureFlagsTypesEnum(Enum): PROJECT_GROUPING = "PROJECT_GROUPING" CYCLE_PROGRESS_CHARTS = "CYCLE_PROGRESS_CHARTS" FILE_SIZE_LIMIT_PRO = "FILE_SIZE_LIMIT_PRO" + TIMELINE_DEPENDENCY = "TIMELINE_DEPENDENCY" TEAMSPACES = "TEAMSPACES" INBOX_STACKING = "INBOX_STACKING" PROJECT_OVERVIEW = "PROJECT_OVERVIEW" PROJECT_UPDATES = "PROJECT_UPDATES" CYCLE_MANUAL_START_STOP = "CYCLE_MANUAL_START_STOP" HOME_ADVANCED = "HOME_ADVANCED" + WORKFLOWS = "WORKFLOWS" + CUSTOMERS = "CUSTOMERS" INTAKE_SETTINGS = "INTAKE_SETTINGS" INITIATIVES = "INITIATIVES" PI_CHAT = "PI_CHAT" PI_DEDUPE = "PI_DEDUPE" - # Silo integrations + # Silo importers and integrations SILO = "SILO" SILO_IMPORTERS = "SILO_IMPORTERS" + FLATFILE_IMPORTER = "FLATFILE_IMPORTER" JIRA_IMPORTER = "JIRA_IMPORTER" JIRA_ISSUE_TYPES_IMPORTER = "JIRA_ISSUE_TYPES_IMPORTER" JIRA_SERVER_IMPORTER = "JIRA_SERVER_IMPORTER" @@ -85,12 +89,15 @@ class FeatureFlagType: project_grouping: bool cycle_progress_charts: bool file_size_limit_pro: bool + timeline_dependency: bool teamspaces: bool inbox_stacking: bool project_overview: bool project_updates: bool cycle_manual_start_stop: bool home_advanced: bool + workflows: bool + customers: bool intake_settings: bool initiatives: bool pi_chat: bool @@ -98,6 +105,7 @@ class FeatureFlagType: # ====== silo integrations ====== silo: bool silo_importers: bool + flatfile_importer: bool jira_importer: bool jira_issue_types_importer: bool jira_server_importer: bool diff --git a/apiserver/plane/graphql/types/issue.py b/apiserver/plane/graphql/types/issue.py index ba2419daaf..489c38c11c 100644 --- a/apiserver/plane/graphql/types/issue.py +++ b/apiserver/plane/graphql/types/issue.py @@ -1,5 +1,6 @@ # python imports from typing import Optional +from dataclasses import dataclass, field from datetime import date, datetime # Third-party library imports @@ -26,6 +27,38 @@ from plane.db.models import ( ) +@strawberry.input +@dataclass +class IssueCreateInputType: + name: str = field() + description_html: Optional[str] = field(default_factory=lambda: "") + priority: Optional[str] = field(default_factory=lambda: "none") + labels: Optional[list[strawberry.ID]] = field(default_factory=lambda: None) + assignees: Optional[list[strawberry.ID]] = field(default_factory=lambda: None) + start_date: Optional[datetime] = field(default_factory=lambda: None) + target_date: Optional[datetime] = field(default_factory=lambda: None) + state: Optional[strawberry.ID] = field(default_factory=lambda: None) + parent: Optional[strawberry.ID] = field(default_factory=lambda: None) + estimate_point: Optional[strawberry.ID] = field(default_factory=lambda: None) + cycle_id: Optional[strawberry.ID] = field(default_factory=lambda: None) + module_ids: Optional[list[strawberry.ID]] = field(default_factory=lambda: None) + + +@strawberry.input +@dataclass +class IssueUpdateInputType: + name: Optional[str] = field(default_factory=lambda: None) + description_html: Optional[str] = field(default_factory=lambda: None) + priority: Optional[str] = field(default_factory=lambda: None) + labels: Optional[list[strawberry.ID]] = field(default_factory=lambda: None) + assignees: Optional[list[strawberry.ID]] = field(default_factory=lambda: None) + start_date: Optional[datetime] = field(default_factory=lambda: None) + target_date: Optional[datetime] = field(default_factory=lambda: None) + state: Optional[strawberry.ID] = field(default_factory=lambda: None) + parent: Optional[strawberry.ID] = field(default_factory=lambda: None) + estimate_point: Optional[strawberry.ID] = field(default_factory=lambda: None) + + @strawberry.type class IssuesInformationObjectType: totalIssues: int diff --git a/apiserver/plane/graphql/types/stickies.py b/apiserver/plane/graphql/types/stickies.py new file mode 100644 index 0000000000..40835d41a8 --- /dev/null +++ b/apiserver/plane/graphql/types/stickies.py @@ -0,0 +1,79 @@ +# Python imports +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + +# Strawberry imports +import strawberry +import strawberry_django +from strawberry.scalars import JSON + +# Module imports +from plane.db.models import Sticky +from plane.graphql.utils.timezone import user_timezone_converter + + +@strawberry.input +@dataclass +class StickyCreateUpdateInputType: + name: Optional[str] = "" + description_html: Optional[str] = "" + background_color: Optional[str] = "" + + +@strawberry_django.type(Sticky) +class StickiesType: + id: strawberry.ID + + name: Optional[str] + + description: Optional[JSON] + description_html: Optional[str] + description_stripped: Optional[str] + description_binary: Optional[str] + + logo_props: JSON + color: Optional[str] + background_color: Optional[str] + sort_order: float + + workspace: strawberry.ID + owner: strawberry.ID + + created_at: Optional[datetime] + updated_at: Optional[datetime] + deleted_at: Optional[datetime] + + created_by: Optional[strawberry.ID] + updated_by: Optional[strawberry.ID] + + @strawberry.field + def workspace(self) -> Optional[strawberry.ID]: + return self.workspace_id + + @strawberry.field + def owner(self) -> Optional[strawberry.ID]: + return self.owner_id + + @strawberry.field + def created_at(self, info) -> Optional[datetime]: + converted_date = user_timezone_converter(info.context.user, self.created_at) + return converted_date + + @strawberry.field + def updated_at(self, info) -> Optional[datetime]: + converted_date = user_timezone_converter(info.context.user, self.updated_at) + return converted_date + + @strawberry.field + def deleted_at(self, info) -> Optional[datetime]: + converted_date = user_timezone_converter(info.context.user, self.deleted_at) + return converted_date + + @strawberry.field + def created_by(self) -> Optional[strawberry.ID]: + return self.created_by_id + + @strawberry.field + def updated_by(self) -> Optional[strawberry.ID]: + return self.updated_by_id diff --git a/apiserver/plane/graphql/utils/workflow.py b/apiserver/plane/graphql/utils/workflow.py new file mode 100644 index 0000000000..03f07a34de --- /dev/null +++ b/apiserver/plane/graphql/utils/workflow.py @@ -0,0 +1,125 @@ +# Python imports +import uuid + +# Module imports +from plane.db.models import State +from plane.ee.models import ( + Workflow, + ProjectFeature, + WorkflowTransition, + WorkflowTransitionApprover, +) + + +class WorkflowStateManager: + def __init__(self, slug: str, project_id: str, user_id: str): + self.project_id = project_id + self.slug = slug + self.user_id = user_id + + def is_project_feature_enabled(self) -> bool: + """Check if the project feature is enabled.""" + + return ProjectFeature.objects.filter( + project_id=self.project_id, is_workflow_enabled=True + ).exists() + + def _get_allowed_transitions(self, current_state_id: str) -> list[int]: + """Get all allowed transition state IDs for a workflow.""" + + return list( + WorkflowTransition.objects.filter( + workflow__state_id=current_state_id, project_id=self.project_id + ).values_list("transition_state_id", flat=True) + ) + + def _get_allowed_approvers( + self, current_state_id: str, transition_state_id: str + ) -> list[int]: + """Get all allowed approvers for the transition state of a workflow.""" + + return list( + WorkflowTransitionApprover.objects.filter( + workflow__state_id=current_state_id, + workflow_transition__transition_state_id=transition_state_id, + project_id=self.project_id, + ).values_list("approver_id", flat=True) + ) + + def _validate_state_transition( + self, current_state_id: str, new_state_id: str + ) -> bool: + """ + Validate if a state transition is allowed for the given issue and user. + + Args: + current_state_id: the current state ID of the updating issue + new_state_id: The target state ID + user_id: The ID of the user attempting the transition + + Returns: + bool: True if the transition is allowed, False otherwise + """ + + if current_state_id == new_state_id: + return True + + # Check if the feature is enabled + is_project_feature_enabled = self.is_project_feature_enabled() + if is_project_feature_enabled is False: + return True + + # Convert to UUID + new_state_id = uuid.UUID(new_state_id) + + # If the issue doesn't have a current state, any state is valid + if not current_state_id: + return True + + # Get allowed transitions + allowed_states = self._get_allowed_transitions( + current_state_id=current_state_id + ) + + # If no transitions are defined, allow all transitions + if not allowed_states or len(allowed_states) == 0: + return True + + if new_state_id not in allowed_states: + return False + + # Get approvers for the transition + allowed_approvers = self._get_allowed_approvers( + current_state_id=current_state_id, transition_state_id=new_state_id + ) + + # If no approvers are defined, allow all users + if not allowed_approvers or len(allowed_approvers) == 0: + return True + + if self.user_id not in allowed_approvers: + return False + + # Transition is allowed + return True + + def _validate_issue_creation(self, state_id: str) -> bool: + """ + False if the creation is allowed, True otherwise + """ + + # Check if the feature is enabled + is_project_feature_enabled = self.is_project_feature_enabled() + if is_project_feature_enabled is False: + return True + + # get the default state for the project if the state is not passed in the query + if not state_id: + state_id = State.objects.get(project_id=self.project_id, default=True).id + + # check if the issue creation is allowed or not for the state + is_issue_creation_allowed = Workflow.objects.filter( + state_id=state_id, project_id=self.project_id, allow_issue_creation=True + ).exists() + + return is_issue_creation_allowed