mirror of
https://github.com/makeplane/plane.git
synced 2026-02-24 20:20:49 +01:00
promote: mobile-preview to preview (#2823)
* [MOBIL-845] chore: implemented stickies feature (#2822) * chore: implemented stickies * chore: implemented pagination for stickies * chore: typo * chore: filtering stickies by user * chore: project pages bulk creation (#2500) * [MOBIL-826] chore: handled issue creation and update if the workflow is enabled in the project (#2629) * chore: handled issue creation and updation if the workflow is enabled in the project * chore: add workflow transition in intake (#2630) --------- Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> * chore: delete bulk stickies (#2831) * chore: import error * [MOBIL-866] chore: implemented work-item create and update v2 mutation and handled the workflow validation (#2881) * chore: validated the issue state and update issue state property * chore: handled workflow create and update * chore: handled issue creation and update V2 * chore: updated types for issue v2 mutation * chore: reverted version check query * chore: updated intake * chore: updated type for start_date and end_date * fix: handled the issue workflow validation in the issue update mutation v2 (#2913) --------- Co-authored-by: Lakhan Baheti <94619783+1akhanBaheti@users.noreply.github.com> Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com>
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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
|
||||
|
||||
487
apiserver/plane/graphql/mutations/issues/base.py
Normal file
487
apiserver/plane/graphql/mutations/issues/base.py
Normal file
@@ -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
|
||||
@@ -26,6 +26,13 @@ from plane.db.models import (
|
||||
Project,
|
||||
)
|
||||
|
||||
@strawberry.input
|
||||
class PageInput:
|
||||
name: str
|
||||
description_html: Optional[str] = strawberry.field(default="<p></p>")
|
||||
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()])]
|
||||
)
|
||||
|
||||
97
apiserver/plane/graphql/mutations/stickies.py
Normal file
97
apiserver/plane/graphql/mutations/stickies.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
),
|
||||
|
||||
68
apiserver/plane/graphql/queries/stickies.py
Normal file
68
apiserver/plane/graphql/queries/stickies.py
Normal file
@@ -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
|
||||
@@ -154,6 +154,7 @@ class YourWorkQuery:
|
||||
)
|
||||
)
|
||||
.values_list("id", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
# pages
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: "<p></p>")
|
||||
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
|
||||
|
||||
79
apiserver/plane/graphql/types/stickies.py
Normal file
79
apiserver/plane/graphql/types/stickies.py
Normal file
@@ -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
|
||||
125
apiserver/plane/graphql/utils/workflow.py
Normal file
125
apiserver/plane/graphql/utils/workflow.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user