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:
guru_sainath
2025-04-09 17:24:36 +05:30
committed by GitHub
parent cca851401f
commit 721cad96f5
14 changed files with 1068 additions and 12 deletions

View File

@@ -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 = {

View File

@@ -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 = {}

View File

@@ -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

View 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

View File

@@ -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()])]
)

View 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

View File

@@ -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
),

View 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

View File

@@ -154,6 +154,7 @@ class YourWorkQuery:
)
)
.values_list("id", flat=True)
.distinct()
)
# pages

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View 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