This commit is contained in:
DCCONSTRUCTIONS
2026-04-18 18:39:25 +03:00
commit 3ba092b60c
4944 changed files with 497564 additions and 0 deletions
@@ -0,0 +1,4 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
+9
View File
@@ -0,0 +1,9 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.apps import AppConfig
class SpaceConfig(AppConfig):
name = "plane.space"
@@ -0,0 +1,9 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from .user import UserLiteSerializer
from .issue import LabelLiteSerializer, IssuePublicSerializer
from .state import StateSerializer
@@ -0,0 +1,62 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from rest_framework import serializers
class BaseSerializer(serializers.ModelSerializer):
id = serializers.PrimaryKeyRelatedField(read_only=True)
class DynamicBaseSerializer(BaseSerializer):
def __init__(self, *args, **kwargs):
# If 'fields' is provided in the arguments, remove it and store it separately.
# This is done so as not to pass this custom argument up to the superclass.
fields = kwargs.pop("fields", None)
# Call the initialization of the superclass.
super().__init__(*args, **kwargs)
# If 'fields' was provided, filter the fields of the serializer accordingly.
if fields is not None:
self.fields = self._filter_fields(fields)
def _filter_fields(self, fields):
"""
Adjust the serializer's fields based on the provided 'fields' list.
:param fields: List or dictionary specifying which fields to include in the serializer.
:return: The updated fields for the serializer.
"""
# Check each field_name in the provided fields.
for field_name in fields:
# If the field is a dictionary (indicating nested fields),
# loop through its keys and values.
if isinstance(field_name, dict):
for key, value in field_name.items():
# If the value of this nested field is a list,
# perform a recursive filter on it.
if isinstance(value, list):
self._filter_fields(self.fields[key], value)
# Create a list to store allowed fields.
allowed = []
for item in fields:
# If the item is a string, it directly represents a field's name.
if isinstance(item, str):
allowed.append(item)
# If the item is a dictionary, it represents a nested field.
# Add the key of this dictionary to the allowed list.
elif isinstance(item, dict):
allowed.append(list(item.keys())[0])
# Convert the current serializer's fields and the allowed fields to sets.
existing = set(self.fields)
allowed = set(allowed)
# Remove fields from the serializer that aren't in the 'allowed' list.
for field_name in existing - allowed:
self.fields.pop(field_name)
return self.fields
@@ -0,0 +1,21 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Module imports
from .base import BaseSerializer
from plane.db.models import Cycle
class CycleBaseSerializer(BaseSerializer):
class Meta:
model = Cycle
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
@@ -0,0 +1,45 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Third Party imports
from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .user import UserLiteSerializer
from .state import StateLiteSerializer
from .project import ProjectLiteSerializer
from .issue import IssueFlatSerializer, LabelLiteSerializer
from plane.db.models import Issue, IntakeIssue
class IntakeIssueSerializer(BaseSerializer):
issue_detail = IssueFlatSerializer(source="issue", read_only=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
class Meta:
model = IntakeIssue
fields = "__all__"
read_only_fields = ["project", "workspace"]
class IntakeIssueLiteSerializer(BaseSerializer):
class Meta:
model = IntakeIssue
fields = ["id", "status", "duplicate_to", "snoozed_till", "source"]
read_only_fields = fields
class IssueStateIntakeSerializer(BaseSerializer):
state_detail = StateLiteSerializer(read_only=True, source="state")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
label_details = LabelLiteSerializer(read_only=True, source="labels", many=True)
assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True)
sub_issues_count = serializers.IntegerField(read_only=True)
bridge_id = serializers.UUIDField(read_only=True)
issue_intake = IntakeIssueLiteSerializer(read_only=True, many=True)
class Meta:
model = Issue
fields = "__all__"
@@ -0,0 +1,456 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Django imports
from django.utils import timezone
# Third Party imports
from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .user import UserLiteSerializer
from .state import StateSerializer, StateLiteSerializer
from .project import ProjectLiteSerializer
from .cycle import CycleBaseSerializer
from .module import ModuleBaseSerializer
from .workspace import WorkspaceLiteSerializer
from plane.db.models import (
User,
Issue,
IssueComment,
IssueAssignee,
IssueLabel,
Label,
CycleIssue,
ModuleIssue,
IssueLink,
FileAsset,
IssueReaction,
CommentReaction,
IssueVote,
IssueRelation,
)
from plane.utils.content_validator import (
validate_html_content,
validate_binary_data,
)
class IssueStateFlatSerializer(BaseSerializer):
state_detail = StateLiteSerializer(read_only=True, source="state")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
class Meta:
model = Issue
fields = ["id", "sequence_id", "name", "state_detail", "project_detail"]
class LabelSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
class Meta:
model = Label
fields = "__all__"
read_only_fields = ["workspace", "project"]
class IssueProjectLiteSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(source="project", read_only=True)
class Meta:
model = Issue
fields = ["id", "project_detail", "name", "sequence_id"]
read_only_fields = fields
class IssueRelationSerializer(BaseSerializer):
issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue")
class Meta:
model = IssueRelation
fields = ["issue_detail", "relation_type", "related_issue", "issue", "id"]
read_only_fields = ["workspace", "project"]
class RelatedIssueSerializer(BaseSerializer):
issue_detail = IssueProjectLiteSerializer(read_only=True, source="issue")
class Meta:
model = IssueRelation
fields = ["issue_detail", "relation_type", "related_issue", "issue", "id"]
read_only_fields = ["workspace", "project"]
class IssueCycleDetailSerializer(BaseSerializer):
cycle_detail = CycleBaseSerializer(read_only=True, source="cycle")
class Meta:
model = CycleIssue
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
class IssueModuleDetailSerializer(BaseSerializer):
module_detail = ModuleBaseSerializer(read_only=True, source="module")
class Meta:
model = ModuleIssue
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
class IssueLinkSerializer(BaseSerializer):
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
class Meta:
model = IssueLink
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
"issue",
]
# Validation if url already exists
def create(self, validated_data):
if IssueLink.objects.filter(url=validated_data.get("url"), issue_id=validated_data.get("issue_id")).exists():
raise serializers.ValidationError({"error": "URL already exists for this Issue"})
return IssueLink.objects.create(**validated_data)
class IssueAttachmentSerializer(BaseSerializer):
class Meta:
model = FileAsset
fields = "__all__"
read_only_fields = [
"created_by",
"updated_by",
"created_at",
"updated_at",
"workspace",
"project",
"issue",
]
class IssueReactionSerializer(BaseSerializer):
class Meta:
model = IssueReaction
fields = ["issue", "reaction", "workspace", "project", "actor"]
read_only_fields = ["workspace", "project", "issue", "actor"]
class IssueSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(read_only=True, source="project")
state_detail = StateSerializer(read_only=True, source="state")
parent_detail = IssueStateFlatSerializer(read_only=True, source="parent")
label_details = LabelSerializer(read_only=True, source="labels", many=True)
assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True)
related_issues = IssueRelationSerializer(read_only=True, source="issue_relation", many=True)
issue_relations = RelatedIssueSerializer(read_only=True, source="issue_related", many=True)
issue_cycle = IssueCycleDetailSerializer(read_only=True)
issue_module = IssueModuleDetailSerializer(read_only=True)
issue_link = IssueLinkSerializer(read_only=True, many=True)
issue_attachment = IssueAttachmentSerializer(read_only=True, many=True)
sub_issues_count = serializers.IntegerField(read_only=True)
issue_reactions = IssueReactionSerializer(read_only=True, many=True)
class Meta:
model = Issue
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
class IssueFlatSerializer(BaseSerializer):
## Contain only flat fields
class Meta:
model = Issue
fields = [
"id",
"name",
"description_json",
"description_html",
"priority",
"start_date",
"target_date",
"sequence_id",
"sort_order",
"is_draft",
]
class CommentReactionLiteSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta:
model = CommentReaction
fields = ["id", "reaction", "comment", "actor_detail"]
class IssueCommentSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
issue_detail = IssueFlatSerializer(read_only=True, source="issue")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
comment_reactions = CommentReactionLiteSerializer(read_only=True, many=True)
is_member = serializers.BooleanField(read_only=True)
class Meta:
model = IssueComment
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"issue",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
##TODO: Find a better way to write this serializer
## Find a better approach to save manytomany?
class IssueCreateSerializer(BaseSerializer):
state_detail = StateSerializer(read_only=True, source="state")
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
assignees = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
write_only=True,
required=False,
)
labels = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()),
write_only=True,
required=False,
)
class Meta:
model = Issue
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
def to_representation(self, instance):
data = super().to_representation(instance)
data["assignees"] = [str(assignee.id) for assignee in instance.assignees.all()]
data["labels"] = [str(label.id) for label in instance.labels.all()]
return data
def validate(self, data):
if (
data.get("start_date", None) is not None
and data.get("target_date", None) is not None
and data.get("start_date", None) > data.get("target_date", None)
):
raise serializers.ValidationError("Start date cannot exceed target date")
# Validate description content for security
if "description_html" in data and data["description_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(data["description_html"])
if not is_valid:
raise serializers.ValidationError({"error": "html content is not valid"})
# Update the data with sanitized HTML if available
if sanitized_html is not None:
data["description_html"] = sanitized_html
if "description_binary" in data and data["description_binary"]:
is_valid, error_msg = validate_binary_data(data["description_binary"])
if not is_valid:
raise serializers.ValidationError({"description_binary": "Invalid binary data"})
return data
def create(self, validated_data):
assignees = validated_data.pop("assignees", None)
labels = validated_data.pop("labels", None)
project_id = self.context["project_id"]
workspace_id = self.context["workspace_id"]
default_assignee_id = self.context["default_assignee_id"]
issue = Issue.objects.create(**validated_data, project_id=project_id)
# Issue Audit Users
created_by_id = issue.created_by_id
updated_by_id = issue.updated_by_id
if assignees is not None and len(assignees):
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee=user,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for user in assignees
],
batch_size=10,
)
else:
# Then assign it to default assignee
if default_assignee_id is not None:
IssueAssignee.objects.create(
assignee_id=default_assignee_id,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
if labels is not None and len(labels):
IssueLabel.objects.bulk_create(
[
IssueLabel(
label=label,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label in labels
],
batch_size=10,
)
return issue
def update(self, instance, validated_data):
assignees = validated_data.pop("assignees", None)
labels = validated_data.pop("labels", None)
# Related models
project_id = instance.project_id
workspace_id = instance.workspace_id
created_by_id = instance.created_by_id
updated_by_id = instance.updated_by_id
if assignees is not None:
IssueAssignee.objects.filter(issue=instance).delete()
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee=user,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for user in assignees
],
batch_size=10,
)
if labels is not None:
IssueLabel.objects.filter(issue=instance).delete()
IssueLabel.objects.bulk_create(
[
IssueLabel(
label=label,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label in labels
],
batch_size=10,
)
# Time updation occues even when other related models are updated
instance.updated_at = timezone.now()
return super().update(instance, validated_data)
class CommentReactionSerializer(BaseSerializer):
class Meta:
model = CommentReaction
fields = "__all__"
read_only_fields = ["workspace", "project", "comment", "actor"]
class IssueVoteSerializer(BaseSerializer):
class Meta:
model = IssueVote
fields = ["issue", "vote", "workspace", "project", "actor"]
read_only_fields = fields
class IssuePublicSerializer(BaseSerializer):
reactions = IssueReactionSerializer(read_only=True, many=True, source="issue_reactions")
votes = IssueVoteSerializer(read_only=True, many=True)
module_ids = serializers.ListField(child=serializers.UUIDField(), required=False)
label_ids = serializers.ListField(child=serializers.UUIDField(), required=False)
assignee_ids = serializers.ListField(child=serializers.UUIDField(), required=False)
class Meta:
model = Issue
fields = [
"id",
"name",
"sequence_id",
"state",
"project",
"workspace",
"priority",
"target_date",
"reactions",
"votes",
"module_ids",
"created_by",
"label_ids",
"assignee_ids",
]
read_only_fields = fields
class LabelLiteSerializer(BaseSerializer):
class Meta:
model = Label
fields = ["id", "name", "color"]
@@ -0,0 +1,21 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Module imports
from .base import BaseSerializer
from plane.db.models import Module
class ModuleBaseSerializer(BaseSerializer):
class Meta:
model = Module
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
@@ -0,0 +1,22 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Module imports
from .base import BaseSerializer
from plane.db.models import Project
class ProjectLiteSerializer(BaseSerializer):
class Meta:
model = Project
fields = [
"id",
"identifier",
"name",
"cover_image",
"icon_prop",
"emoji",
"description",
]
read_only_fields = fields
@@ -0,0 +1,21 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Module imports
from .base import BaseSerializer
from plane.db.models import State
class StateSerializer(BaseSerializer):
class Meta:
model = State
fields = "__all__"
read_only_fields = ["workspace", "project"]
class StateLiteSerializer(BaseSerializer):
class Meta:
model = State
fields = ["id", "name", "color", "group"]
read_only_fields = fields
@@ -0,0 +1,22 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Module imports
from .base import BaseSerializer
from plane.db.models import User
class UserLiteSerializer(BaseSerializer):
class Meta:
model = User
fields = [
"id",
"first_name",
"last_name",
"avatar",
"avatar_url",
"is_bot",
"display_name",
]
read_only_fields = ["id", "is_bot"]
@@ -0,0 +1,14 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Module imports
from .base import BaseSerializer
from plane.db.models import Workspace
class WorkspaceLiteSerializer(BaseSerializer):
class Meta:
model = Workspace
fields = ["name", "slug", "id"]
read_only_fields = fields
@@ -0,0 +1,11 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from .intake import urlpatterns as intake_urls
from .issue import urlpatterns as issue_urls
from .project import urlpatterns as project_urls
from .asset import urlpatterns as asset_urls
urlpatterns = [*intake_urls, *issue_urls, *project_urls, *asset_urls]
@@ -0,0 +1,36 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Django imports
from django.urls import path
# Module imports
from plane.space.views import (
EntityAssetEndpoint,
AssetRestoreEndpoint,
EntityBulkAssetEndpoint,
)
urlpatterns = [
path(
"assets/v2/anchor/<str:anchor>/",
EntityAssetEndpoint.as_view(),
name="entity-asset",
),
path(
"assets/v2/anchor/<str:anchor>/<uuid:pk>/",
EntityAssetEndpoint.as_view(),
name="entity-asset",
),
path(
"assets/v2/anchor/<str:anchor>/restore/<uuid:pk>/",
AssetRestoreEndpoint.as_view(),
name="asset-restore",
),
path(
"assets/v2/anchor/<str:anchor>/<uuid:entity_id>/bulk/",
EntityBulkAssetEndpoint.as_view(),
name="entity-bulk-asset",
),
]
@@ -0,0 +1,35 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.urls import path
from plane.space.views import (
IntakeIssuePublicViewSet,
WorkspaceProjectDeployBoardEndpoint,
)
urlpatterns = [
path(
"anchor/<str:anchor>/intakes/<uuid:intake_id>/intake-issues/",
IntakeIssuePublicViewSet.as_view({"get": "list", "post": "create"}),
name="intake-issue",
),
path(
"anchor/<str:anchor>/intakes/<uuid:intake_id>/inbox-issues/",
IntakeIssuePublicViewSet.as_view({"get": "list", "post": "create"}),
name="inbox-issue",
),
path(
"anchor/<str:anchor>/intakes/<uuid:intake_id>/intake-issues/<uuid:pk>/",
IntakeIssuePublicViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
name="intake-issue",
),
path(
"workspaces/<str:slug>/project-boards/",
WorkspaceProjectDeployBoardEndpoint.as_view(),
name="workspace-project-boards",
),
]
@@ -0,0 +1,57 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.urls import path
from plane.space.views import (
IssueRetrievePublicEndpoint,
IssueCommentPublicViewSet,
IssueReactionPublicViewSet,
CommentReactionPublicViewSet,
IssueVotePublicViewSet,
)
urlpatterns = [
path(
"anchor/<str:anchor>/issues/<uuid:issue_id>/",
IssueRetrievePublicEndpoint.as_view(),
name="workspace-project-boards",
),
path(
"anchor/<str:anchor>/issues/<uuid:issue_id>/comments/",
IssueCommentPublicViewSet.as_view({"get": "list", "post": "create"}),
name="issue-comments-project-board",
),
path(
"anchor/<str:anchor>/issues/<uuid:issue_id>/comments/<uuid:pk>/",
IssueCommentPublicViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
name="issue-comments-project-board",
),
path(
"anchor/<str:anchor>/issues/<uuid:issue_id>/reactions/",
IssueReactionPublicViewSet.as_view({"get": "list", "post": "create"}),
name="issue-reactions-project-board",
),
path(
"anchor/<str:anchor>/issues/<uuid:issue_id>/reactions/<str:reaction_code>/",
IssueReactionPublicViewSet.as_view({"delete": "destroy"}),
name="issue-reactions-project-board",
),
path(
"anchor/<str:anchor>/comments/<uuid:comment_id>/reactions/",
CommentReactionPublicViewSet.as_view({"get": "list", "post": "create"}),
name="comment-reactions-project-board",
),
path(
"anchor/<str:anchor>/comments/<uuid:comment_id>/reactions/<str:reaction_code>/",
CommentReactionPublicViewSet.as_view({"delete": "destroy"}),
name="comment-reactions-project-board",
),
path(
"anchor/<str:anchor>/issues/<uuid:issue_id>/votes/",
IssueVotePublicViewSet.as_view({"get": "list", "post": "create", "delete": "destroy"}),
name="issue-vote-project-board",
),
]
@@ -0,0 +1,66 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.urls import path
from plane.space.views import (
ProjectDeployBoardPublicSettingsEndpoint,
ProjectIssuesPublicEndpoint,
WorkspaceProjectAnchorEndpoint,
ProjectCyclesEndpoint,
ProjectModulesEndpoint,
ProjectStatesEndpoint,
ProjectLabelsEndpoint,
ProjectMembersEndpoint,
ProjectMetaDataEndpoint,
)
urlpatterns = [
path(
"anchor/<str:anchor>/meta/",
ProjectMetaDataEndpoint.as_view(),
name="project-meta",
),
path(
"anchor/<str:anchor>/settings/",
ProjectDeployBoardPublicSettingsEndpoint.as_view(),
name="project-deploy-board-settings",
),
path(
"anchor/<str:anchor>/issues/",
ProjectIssuesPublicEndpoint.as_view(),
name="project-deploy-board",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/anchor/",
WorkspaceProjectAnchorEndpoint.as_view(),
name="project-deploy-board",
),
path(
"anchor/<str:anchor>/cycles/",
ProjectCyclesEndpoint.as_view(),
name="project-cycles",
),
path(
"anchor/<str:anchor>/modules/",
ProjectModulesEndpoint.as_view(),
name="project-modules",
),
path(
"anchor/<str:anchor>/states/",
ProjectStatesEndpoint.as_view(),
name="project-states",
),
path(
"anchor/<str:anchor>/labels/",
ProjectLabelsEndpoint.as_view(),
name="project-labels",
),
path(
"anchor/<str:anchor>/members/",
ProjectMembersEndpoint.as_view(),
name="project-members",
),
]
@@ -0,0 +1,251 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Q, UUIDField, Value, F, Case, When, JSONField, CharField
from django.db.models.functions import Coalesce, JSONObject, Concat
from django.db.models import QuerySet
from typing import List, Optional, Dict, Any, Union
# Module imports
from plane.db.models import (
Cycle,
Issue,
Label,
Module,
Project,
ProjectMember,
State,
WorkspaceMember,
)
def issue_queryset_grouper(
queryset: QuerySet[Issue], group_by: Optional[str], sub_group_by: Optional[str]
) -> QuerySet[Issue]:
FIELD_MAPPER = {
"label_ids": "labels__id",
"assignee_ids": "assignees__id",
"module_ids": "issue_module__module_id",
}
GROUP_FILTER_MAPPER = {
"assignees__id": Q(issue_assignee__deleted_at__isnull=True),
"labels__id": Q(label_issue__deleted_at__isnull=True),
"issue_module__module_id": Q(issue_module__deleted_at__isnull=True),
}
for group_key in [group_by, sub_group_by]:
if group_key in GROUP_FILTER_MAPPER:
queryset = queryset.filter(GROUP_FILTER_MAPPER[group_key])
annotations_map = {
"assignee_ids": (
"assignees__id",
~Q(assignees__id__isnull=True) & Q(issue_assignee__deleted_at__isnull=True),
),
"label_ids": (
"labels__id",
~Q(labels__id__isnull=True) & Q(label_issue__deleted_at__isnull=True),
),
"module_ids": (
"issue_module__module_id",
~Q(issue_module__module_id__isnull=True),
),
}
default_annotations = {
key: Coalesce(
ArrayAgg(field, distinct=True, filter=condition),
Value([], output_field=ArrayField(UUIDField())),
)
for key, (field, condition) in annotations_map.items()
if FIELD_MAPPER.get(key) != group_by or FIELD_MAPPER.get(key) != sub_group_by
}
return queryset.annotate(**default_annotations)
def issue_on_results(
issues: QuerySet[Issue], group_by: Optional[str], sub_group_by: Optional[str]
) -> List[Dict[str, Any]]:
FIELD_MAPPER = {
"labels__id": "label_ids",
"assignees__id": "assignee_ids",
"issue_module__module_id": "module_ids",
}
original_list = ["assignee_ids", "label_ids", "module_ids"]
required_fields = [
"id",
"name",
"state_id",
"sort_order",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"created_by",
"state__group",
]
if group_by in FIELD_MAPPER:
original_list.remove(FIELD_MAPPER[group_by])
original_list.append(group_by)
if sub_group_by in FIELD_MAPPER:
original_list.remove(FIELD_MAPPER[sub_group_by])
original_list.append(sub_group_by)
required_fields.extend(original_list)
issues = issues.annotate(
vote_items=ArrayAgg(
Case(
When(
votes__isnull=False,
votes__deleted_at__isnull=True,
then=JSONObject(
vote=F("votes__vote"),
actor_details=JSONObject(
id=F("votes__actor__id"),
first_name=F("votes__actor__first_name"),
last_name=F("votes__actor__last_name"),
avatar=F("votes__actor__avatar"),
avatar_url=Case(
When(
votes__actor__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
F("votes__actor__avatar_asset"),
Value("/"),
),
),
default=F("votes__actor__avatar"),
output_field=CharField(),
),
display_name=F("votes__actor__display_name"),
),
),
),
default=None,
output_field=JSONField(),
),
filter=Q(votes__isnull=False, votes__deleted_at__isnull=True),
distinct=True,
),
reaction_items=ArrayAgg(
Case(
When(
issue_reactions__isnull=False,
issue_reactions__deleted_at__isnull=True,
then=JSONObject(
reaction=F("issue_reactions__reaction"),
actor_details=JSONObject(
id=F("issue_reactions__actor__id"),
first_name=F("issue_reactions__actor__first_name"),
last_name=F("issue_reactions__actor__last_name"),
avatar=F("issue_reactions__actor__avatar"),
avatar_url=Case(
When(
issue_reactions__actor__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
F("issue_reactions__actor__avatar_asset"),
Value("/"),
),
),
default=F("issue_reactions__actor__avatar"),
output_field=CharField(),
),
display_name=F("issue_reactions__actor__display_name"),
),
),
),
default=None,
output_field=JSONField(),
),
filter=Q(issue_reactions__isnull=False, issue_reactions__deleted_at__isnull=True),
distinct=True,
),
).values(*required_fields, "vote_items", "reaction_items")
return issues
def issue_group_values(
field: str,
slug: str,
project_id: Optional[str] = None,
filters: Dict[str, Any] = {},
queryset: Optional[QuerySet] = None,
) -> List[Union[str, Any]]:
if field == "state_id":
queryset = State.objects.filter(is_triage=False, workspace__slug=slug).values_list("id", flat=True)
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
if field == "labels__id":
queryset = Label.objects.filter(workspace__slug=slug).values_list("id", flat=True)
if project_id:
return list(queryset.filter(project_id=project_id)) + ["None"]
else:
return list(queryset) + ["None"]
if field == "assignees__id":
if project_id:
return ProjectMember.objects.filter(
workspace__slug=slug, project_id=project_id, is_active=True
).values_list("member_id", flat=True)
else:
return list(
WorkspaceMember.objects.filter(workspace__slug=slug, is_active=True).values_list("member_id", flat=True)
)
if field == "issue_module__module_id":
queryset = Module.objects.filter(workspace__slug=slug).values_list("id", flat=True)
if project_id:
return list(queryset.filter(project_id=project_id)) + ["None"]
else:
return list(queryset) + ["None"]
if field == "cycle_id":
queryset = Cycle.objects.filter(workspace__slug=slug).values_list("id", flat=True)
if project_id:
return list(queryset.filter(project_id=project_id)) + ["None"]
else:
return list(queryset) + ["None"]
if field == "project_id":
queryset = Project.objects.filter(workspace__slug=slug).values_list("id", flat=True)
return list(queryset)
if field == "priority":
return ["low", "medium", "high", "urgent", "none"]
if field == "state__group":
return ["backlog", "unstarted", "started", "completed", "cancelled"]
if field == "target_date":
queryset = queryset.values_list("target_date", flat=True).distinct()
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
if field == "start_date":
queryset = queryset.values_list("start_date", flat=True).distinct()
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
if field == "created_by":
queryset = queryset.values_list("created_by", flat=True).distinct()
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
return []
@@ -0,0 +1,33 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from .project import (
ProjectDeployBoardPublicSettingsEndpoint,
WorkspaceProjectDeployBoardEndpoint,
WorkspaceProjectAnchorEndpoint,
ProjectMembersEndpoint,
)
from .issue import (
IssueCommentPublicViewSet,
IssueReactionPublicViewSet,
CommentReactionPublicViewSet,
IssueVotePublicViewSet,
IssueRetrievePublicEndpoint,
ProjectIssuesPublicEndpoint,
)
from .intake import IntakeIssuePublicViewSet
from .cycle import ProjectCyclesEndpoint
from .module import ProjectModulesEndpoint
from .state import ProjectStatesEndpoint
from .label import ProjectLabelsEndpoint
from .asset import EntityAssetEndpoint, AssetRestoreEndpoint, EntityBulkAssetEndpoint
from .meta import ProjectMetaDataEndpoint
@@ -0,0 +1,226 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Python imports
import uuid
# Django imports
from django.conf import settings
from django.http import HttpResponseRedirect
from django.utils import timezone
# Third party imports
from rest_framework import status
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from plane.db.models import DeployBoard, FileAsset
from plane.settings.storage import S3Storage
# Module imports
from .base import BaseAPIView
class EntityAssetEndpoint(BaseAPIView):
def get_permissions(self):
if self.request.method == "GET":
permission_classes = [AllowAny]
else:
permission_classes = [IsAuthenticated]
return [permission() for permission in permission_classes]
def get(self, request, anchor, pk):
# Get the deploy board
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
# Check if the project is published
if not deploy_board:
return Response(
{"error": "Requested resource could not be found."},
status=status.HTTP_404_NOT_FOUND,
)
# get the asset id
asset = FileAsset.objects.get(
workspace_id=deploy_board.workspace_id,
pk=pk,
entity_type__in=[
FileAsset.EntityTypeContext.ISSUE_DESCRIPTION,
FileAsset.EntityTypeContext.COMMENT_DESCRIPTION,
],
)
# Check if the asset is uploaded
if not asset.is_uploaded:
return Response(
{"error": "The requested asset could not be found."},
status=status.HTTP_404_NOT_FOUND,
)
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
signed_url = storage.generate_presigned_url(object_name=asset.asset.name)
# Redirect to the signed URL
return HttpResponseRedirect(signed_url)
def post(self, request, anchor):
# Get the deploy board
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
# Check if the project is published
if not deploy_board:
return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND)
# Get the asset
name = request.data.get("name")
type = request.data.get("type", "image/jpeg")
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
entity_type = request.data.get("entity_type", "")
entity_identifier = request.data.get("entity_identifier")
# Check if the entity type is allowed
if entity_type not in FileAsset.EntityTypeContext.values:
return Response(
{"error": "Invalid entity type.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if the file type is allowed
allowed_types = [
"image/jpeg",
"image/png",
"image/webp",
"image/jpg",
"image/gif",
]
if type not in allowed_types:
return Response(
{
"error": "Invalid file type. Only JPEG, PNG, WebP, JPG and GIF files are allowed.",
"status": False,
},
status=status.HTTP_400_BAD_REQUEST,
)
# asset key
asset_key = f"{deploy_board.workspace_id}/{uuid.uuid4().hex}-{name}"
# Create a File Asset
asset = FileAsset.objects.create(
attributes={"name": name, "type": type, "size": size},
asset=asset_key,
size=size,
workspace=deploy_board.workspace,
created_by=request.user,
entity_type=entity_type,
project_id=deploy_board.project_id,
comment_id=entity_identifier,
)
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size)
# Return the presigned URL
return Response(
{
"upload_data": presigned_url,
"asset_id": str(asset.id),
"asset_url": asset.asset_url,
},
status=status.HTTP_200_OK,
)
def patch(self, request, anchor, pk):
# Get the deploy board
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
# Check if the project is published
if not deploy_board:
return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND)
# get the asset id
asset = FileAsset.objects.get(id=pk, workspace=deploy_board.workspace)
# get the storage metadata
asset.is_uploaded = True
# get the storage metadata
if not asset.storage_metadata:
get_asset_object_metadata.delay(str(asset.id))
# update the attributes
asset.attributes = request.data.get("attributes", asset.attributes)
# save the asset
asset.save(update_fields=["attributes", "is_uploaded"])
return Response(status=status.HTTP_204_NO_CONTENT)
def delete(self, request, anchor, pk):
# Get the deploy board
deploy_board = DeployBoard.objects.filter(anchor=anchor, entity_name="project").first()
# Check if the project is published
if not deploy_board:
return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND)
# Get the asset
asset = FileAsset.objects.get(id=pk, workspace=deploy_board.workspace, project_id=deploy_board.project_id)
# Check deleted assets
asset.is_deleted = True
asset.deleted_at = timezone.now()
# Save the asset
asset.save(update_fields=["is_deleted", "deleted_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
class AssetRestoreEndpoint(BaseAPIView):
"""Endpoint to restore a deleted assets."""
def post(self, request, anchor, pk):
# Get the deploy board
deploy_board = DeployBoard.objects.filter(anchor=anchor, entity_name="project").first()
# Check if the project is published
if not deploy_board:
return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND)
# Get the asset
asset = FileAsset.all_objects.get(id=pk, workspace=deploy_board.workspace)
asset.is_deleted = False
asset.deleted_at = None
asset.save(update_fields=["is_deleted", "deleted_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
class EntityBulkAssetEndpoint(BaseAPIView):
"""Endpoint to bulk update assets."""
def post(self, request, anchor, entity_id):
# Get the deploy board
deploy_board = DeployBoard.objects.filter(anchor=anchor, entity_name="project").first()
# Check if the project is published
if not deploy_board:
return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND)
asset_ids = request.data.get("asset_ids", [])
# Check if the asset ids are provided
if not asset_ids:
return Response({"error": "No asset ids provided."}, status=status.HTTP_400_BAD_REQUEST)
# get the asset id
assets = FileAsset.objects.filter(
id__in=asset_ids,
workspace=deploy_board.workspace,
project_id=deploy_board.project_id,
)
asset = assets.first()
# Check if the asset is uploaded
if not asset:
return Response(
{"error": "The requested asset could not be found."},
status=status.HTTP_404_NOT_FOUND,
)
# Check if the entity type is allowed
if asset.entity_type == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION:
# update the attributes
assets.update(comment_id=entity_id)
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -0,0 +1,208 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Python imports
import zoneinfo
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import IntegrityError
# Django imports
from django.urls import resolve
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
# Third part imports
from rest_framework import status
from rest_framework.exceptions import APIException
from rest_framework.filters import SearchFilter
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
# Module imports
from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator
from plane.authentication.session import BaseSessionAuthentication
class TimezoneMixin:
"""
This enables timezone conversion according
to the user set timezone
"""
def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
if request.user.is_authenticated:
timezone.activate(zoneinfo.ZoneInfo(request.user.user_timezone))
else:
timezone.deactivate()
class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
model = None
permission_classes = [IsAuthenticated]
filter_backends = (DjangoFilterBackend, SearchFilter)
authentication_classes = [BaseSessionAuthentication]
filterset_fields = []
search_fields = []
def get_queryset(self):
try:
return self.model.objects.all()
except Exception as e:
log_exception(e)
raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST)
def handle_exception(self, exc):
"""
Handle any exception that occurs, by returning an appropriate response,
or re-raising the error.
"""
try:
response = super().handle_exception(exc)
return response
except Exception as e:
if isinstance(e, IntegrityError):
return Response(
{"error": "The payload is not valid"},
status=status.HTTP_400_BAD_REQUEST,
)
if isinstance(e, ValidationError):
return Response(
{"error": "Please provide valid detail"},
status=status.HTTP_400_BAD_REQUEST,
)
if isinstance(e, ObjectDoesNotExist):
return Response(
{"error": "The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)
if isinstance(e, KeyError):
log_exception(e)
return Response(
{"error": "The required key does not exist."},
status=status.HTTP_400_BAD_REQUEST,
)
log_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
def dispatch(self, request, *args, **kwargs):
try:
response = super().dispatch(request, *args, **kwargs)
if settings.DEBUG:
from django.db import connection
print(f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}")
return response
except Exception as exc:
response = self.handle_exception(exc)
return exc
@property
def workspace_slug(self):
return self.kwargs.get("slug", None)
@property
def project_id(self):
project_id = self.kwargs.get("project_id", None)
if project_id:
return project_id
if resolve(self.request.path_info).url_name == "project":
return self.kwargs.get("pk", None)
class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
permission_classes = [IsAuthenticated]
filter_backends = (DjangoFilterBackend, SearchFilter)
filterset_fields = []
search_fields = []
authentication_classes = [BaseSessionAuthentication]
def filter_queryset(self, queryset):
for backend in list(self.filter_backends):
queryset = backend().filter_queryset(self.request, queryset, self)
return queryset
def handle_exception(self, exc):
"""
Handle any exception that occurs, by returning an appropriate response,
or re-raising the error.
"""
try:
response = super().handle_exception(exc)
return response
except Exception as e:
if isinstance(e, IntegrityError):
return Response(
{"error": "The payload is not valid"},
status=status.HTTP_400_BAD_REQUEST,
)
if isinstance(e, ValidationError):
return Response(
{"error": "Please provide valid detail"},
status=status.HTTP_400_BAD_REQUEST,
)
if isinstance(e, ObjectDoesNotExist):
return Response(
{"error": "The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)
if isinstance(e, KeyError):
return Response(
{"error": "The required key does not exist."},
status=status.HTTP_400_BAD_REQUEST,
)
log_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
def dispatch(self, request, *args, **kwargs):
try:
response = super().dispatch(request, *args, **kwargs)
if settings.DEBUG:
from django.db import connection
print(f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}")
return response
except Exception as exc:
response = self.handle_exception(exc)
return exc
@property
def workspace_slug(self):
return self.kwargs.get("slug", None)
@property
def project_id(self):
return self.kwargs.get("project_id", None)
@@ -0,0 +1,28 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Third Party imports
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.db.models import DeployBoard, Cycle
class ProjectCyclesEndpoint(BaseAPIView):
permission_classes = [AllowAny]
def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
if not deploy_board:
return Response({"error": "Invalid anchor"}, status=status.HTTP_404_NOT_FOUND)
cycles = Cycle.objects.filter(
workspace__slug=deploy_board.workspace.slug,
project_id=deploy_board.project_id,
).values("id", "name")
return Response(cycles, status=status.HTTP_200_OK)
@@ -0,0 +1,280 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Python imports
import json
# Django import
from django.utils import timezone
from django.db.models import Q, OuterRef, Func, F, Prefetch
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .base import BaseViewSet
from plane.db.models import IntakeIssue, Issue, IssueLink, FileAsset, DeployBoard, State, StateGroup
from plane.app.serializers import (
IssueSerializer,
IntakeIssueSerializer,
IssueCreateSerializer,
IssueStateIntakeSerializer,
)
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models.intake import SourceType
class IntakeIssuePublicViewSet(BaseViewSet):
serializer_class = IntakeIssueSerializer
model = IntakeIssue
filterset_fields = ["status"]
def get_queryset(self):
project_deploy_board = DeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
if project_deploy_board is not None:
return self.filter_queryset(
super()
.get_queryset()
.filter(
Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
intake_id=self.kwargs.get("intake_id"),
)
.select_related("issue", "workspace", "project")
)
return IntakeIssue.objects.none()
def list(self, request, anchor, intake_id):
project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project")
if project_deploy_board.intake is None:
return Response(
{"error": "Intake is not enabled for this Project Board"},
status=status.HTTP_400_BAD_REQUEST,
)
filters = issue_filters(request.query_params, "GET")
issues = (
Issue.objects.filter(
issue_intake__intake_id=intake_id,
workspace_id=project_deploy_board.workspace_id,
project_id=project_deploy_board.project_id,
)
.filter(**filters)
.annotate(bridge_id=F("issue_intake__id"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels")
.order_by("issue_intake__snoozed_till", "issue_intake__status")
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.prefetch_related(
Prefetch(
"issue_intake",
queryset=IntakeIssue.objects.only("status", "duplicate_to", "snoozed_till", "source"),
)
)
)
issues_data = IssueStateIntakeSerializer(issues, many=True).data
return Response(issues_data, status=status.HTTP_200_OK)
def create(self, request, anchor, intake_id):
project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project")
if project_deploy_board.intake is None:
return Response(
{"error": "Intake is not enabled for this Project Board"},
status=status.HTTP_400_BAD_REQUEST,
)
if not request.data.get("issue", {}).get("name", False):
return Response({"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST)
# Check for valid priority
if request.data.get("issue", {}).get("priority", "none") not in [
"low",
"medium",
"high",
"urgent",
"none",
]:
return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST)
# get the triage state
triage_state = State.triage_objects.filter(
project_id=project_deploy_board.project_id, workspace_id=project_deploy_board.workspace_id
).first()
if not triage_state:
triage_state = State.objects.create(
name="Triage",
group=StateGroup.TRIAGE.value,
project_id=project_deploy_board.project_id,
workspace_id=project_deploy_board.workspace_id,
color="#4E5355",
sequence=65000,
default=False,
)
# create an issue
issue = Issue.objects.create(
name=request.data.get("issue", {}).get("name"),
description_json=request.data.get("issue", {}).get("description_json", {}),
description_html=request.data.get("issue", {}).get("description_html", "<p></p>"),
priority=request.data.get("issue", {}).get("priority", "low"),
project_id=project_deploy_board.project_id,
state_id=triage_state.id,
)
# Create an Issue Activity
issue_activity.delay(
type="issue.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_deploy_board.project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
# create an intake issue
IntakeIssue.objects.create(
intake_id=intake_id,
project_id=project_deploy_board.project_id,
issue=issue,
source=SourceType.IN_APP,
)
serializer = IssueStateIntakeSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK)
def partial_update(self, request, anchor, intake_id, pk):
project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project")
if project_deploy_board.intake is None:
return Response(
{"error": "Intake is not enabled for this Project Board"},
status=status.HTTP_400_BAD_REQUEST,
)
intake_issue = IntakeIssue.objects.get(
pk=pk,
workspace_id=project_deploy_board.workspace_id,
project_id=project_deploy_board.project_id,
intake_id=intake_id,
)
# Get the project member
if str(intake_issue.created_by_id) != str(request.user.id):
return Response(
{"error": "You cannot edit intake issues"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get issue data
issue_data = request.data.pop("issue", False)
issue = Issue.objects.get(
pk=intake_issue.issue_id,
workspace_id=project_deploy_board.workspace_id,
project_id=project_deploy_board.project_id,
)
# viewers and guests since only viewers and guests
issue_data = {
"name": issue_data.get("name", issue.name),
"description_html": issue_data.get("description_html", issue.description_html),
"description_json": issue_data.get("description_json", issue.description_json),
}
issue_serializer = IssueCreateSerializer(
issue,
data=issue_data,
partial=True,
context={"project_id": project_deploy_board.project_id, "allow_triage_state": True},
)
if issue_serializer.is_valid():
current_instance = issue
# Log all the updates
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
if issue is not None:
issue_activity.delay(
type="issue.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_deploy_board.project_id),
current_instance=json.dumps(IssueSerializer(current_instance).data, cls=DjangoJSONEncoder),
epoch=int(timezone.now().timestamp()),
)
issue_serializer.save()
return Response(issue_serializer.data, status=status.HTTP_200_OK)
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def retrieve(self, request, anchor, intake_id, pk):
project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project")
if project_deploy_board.intake is None:
return Response(
{"error": "Intake is not enabled for this Project Board"},
status=status.HTTP_400_BAD_REQUEST,
)
intake_issue = IntakeIssue.objects.get(
pk=pk,
workspace_id=project_deploy_board.workspace_id,
project_id=project_deploy_board.project_id,
intake_id=intake_id,
)
issue = Issue.objects.get(
pk=intake_issue.issue_id,
workspace_id=project_deploy_board.workspace_id,
project_id=project_deploy_board.project_id,
)
serializer = IssueStateIntakeSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, anchor, intake_id, pk):
project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project")
if project_deploy_board.intake is None:
return Response(
{"error": "Intake is not enabled for this Project Board"},
status=status.HTTP_400_BAD_REQUEST,
)
intake_issue = IntakeIssue.objects.get(
pk=pk,
workspace_id=project_deploy_board.workspace_id,
project_id=project_deploy_board.project_id,
intake_id=intake_id,
)
if str(intake_issue.created_by_id) != str(request.user.id):
return Response(
{"error": "You cannot delete intake issue"},
status=status.HTTP_400_BAD_REQUEST,
)
intake_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -0,0 +1,773 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Python imports
import json
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models.functions import Coalesce, JSONObject
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
from django.db.models import (
Exists,
F,
Q,
Prefetch,
UUIDField,
Case,
When,
JSONField,
Value,
OuterRef,
Func,
CharField,
Subquery,
)
from django.db.models.functions import Concat
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny, IsAuthenticated
# Module imports
from .base import BaseAPIView, BaseViewSet
# fetch the space app grouper function separately
from plane.space.utils.grouper import (
issue_group_values,
issue_on_results,
issue_queryset_grouper,
)
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
from plane.app.serializers import (
CommentReactionSerializer,
IssueCommentSerializer,
IssueReactionSerializer,
IssueVoteSerializer,
)
from plane.db.models import (
Issue,
IssueComment,
IssueLink,
IssueReaction,
ProjectMember,
CommentReaction,
DeployBoard,
IssueVote,
ProjectPublicMember,
FileAsset,
CycleIssue,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.issue_filters import issue_filters
class ProjectIssuesPublicEndpoint(BaseAPIView):
permission_classes = [AllowAny]
def get(self, request, anchor):
filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at")
deploy_board = DeployBoard.objects.filter(anchor=anchor, entity_name="project").first()
if not deploy_board:
return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND)
project_id = deploy_board.entity_identifier
slug = deploy_board.workspace.slug
issue_queryset = (
Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.prefetch_related(Prefetch("votes", queryset=IssueVote.objects.select_related("actor")))
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1]
)
)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
).distinct()
issue_queryset = issue_queryset.filter(**filters)
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset, order_by_param=order_by_param
)
# Group by
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
# issue queryset
issue_queryset = issue_queryset_grouper(queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by)
if group_by:
if sub_group_by:
if group_by == sub_group_by:
return Response(
{"error": "Group by and sub group by cannot have same parameters"},
status=status.HTTP_400_BAD_REQUEST,
)
else:
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
paginator_cls=SubGroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
count_filter=Q(
Q(issue_intake__status=1)
| Q(issue_intake__status=-1)
| Q(issue_intake__status=2)
| Q(issue_intake__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
else:
# Group paginate
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
paginator_cls=GroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
count_filter=Q(
Q(issue_intake__status=1)
| Q(issue_intake__status=-1)
| Q(issue_intake__status=2)
| Q(issue_intake__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
else:
return self.paginate(
order_by=order_by_param,
request=request,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(group_by=group_by, issues=issues, sub_group_by=sub_group_by),
)
class IssueCommentPublicViewSet(BaseViewSet):
serializer_class = IssueCommentSerializer
model = IssueComment
filterset_fields = ["issue__id", "workspace__id"]
def get_permissions(self):
if self.action in ["list", "retrieve"]:
self.permission_classes = [AllowAny]
else:
self.permission_classes = [IsAuthenticated]
return super(IssueCommentPublicViewSet, self).get_permissions()
def get_queryset(self):
try:
project_deploy_board = DeployBoard.objects.get(anchor=self.kwargs.get("anchor"), entity_name="project")
if project_deploy_board.is_comments_enabled:
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace_id=project_deploy_board.workspace_id)
.filter(issue_id=self.kwargs.get("issue_id"))
.filter(access="EXTERNAL")
.select_related("project")
.select_related("workspace")
.select_related("issue")
.annotate(
is_member=Exists(
ProjectMember.objects.filter(
workspace_id=project_deploy_board.workspace_id,
project_id=project_deploy_board.project_id,
member_id=self.request.user.id,
is_active=True,
)
)
)
.distinct()
).order_by("created_at")
return IssueComment.objects.none()
except DeployBoard.DoesNotExist:
return IssueComment.objects.none()
def create(self, request, anchor, issue_id):
project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project")
if not project_deploy_board.is_comments_enabled:
return Response(
{"error": "Comments are not enabled for this project"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = IssueCommentSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_deploy_board.project_id,
issue_id=issue_id,
actor=request.user,
access="EXTERNAL",
)
issue_activity.delay(
type="comment.activity.created",
requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_deploy_board.project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
if not ProjectMember.objects.filter(
project_id=project_deploy_board.project_id,
member=request.user,
is_active=True,
).exists():
# Add the user for workspace tracking
_ = ProjectPublicMember.objects.get_or_create(
project_id=project_deploy_board.project_id, member=request.user
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def partial_update(self, request, anchor, issue_id, pk):
project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project")
if not project_deploy_board.is_comments_enabled:
return Response(
{"error": "Comments are not enabled for this project"},
status=status.HTTP_400_BAD_REQUEST,
)
comment = IssueComment.objects.get(pk=pk, actor=request.user)
serializer = IssueCommentSerializer(comment, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
issue_activity.delay(
type="comment.activity.updated",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_deploy_board.project_id),
current_instance=json.dumps(IssueCommentSerializer(comment).data, cls=DjangoJSONEncoder),
epoch=int(timezone.now().timestamp()),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, anchor, issue_id, pk):
project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project")
if not project_deploy_board.is_comments_enabled:
return Response(
{"error": "Comments are not enabled for this project"},
status=status.HTTP_400_BAD_REQUEST,
)
comment = IssueComment.objects.get(pk=pk, actor=request.user)
issue_activity.delay(
type="comment.activity.deleted",
requested_data=json.dumps({"comment_id": str(pk)}),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_deploy_board.project_id),
current_instance=json.dumps(IssueCommentSerializer(comment).data, cls=DjangoJSONEncoder),
epoch=int(timezone.now().timestamp()),
)
comment.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class IssueReactionPublicViewSet(BaseViewSet):
serializer_class = IssueReactionSerializer
model = IssueReaction
def get_queryset(self):
try:
project_deploy_board = DeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
if project_deploy_board.is_reactions_enabled:
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id"))
.order_by("-created_at")
.distinct()
)
return IssueReaction.objects.none()
except DeployBoard.DoesNotExist:
return IssueReaction.objects.none()
def create(self, request, anchor, issue_id):
project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project")
if not project_deploy_board.is_reactions_enabled:
return Response(
{"error": "Reactions are not enabled for this project board"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = IssueReactionSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_deploy_board.project_id,
issue_id=issue_id,
actor=request.user,
)
if not ProjectMember.objects.filter(
project_id=project_deploy_board.project_id,
member=request.user,
is_active=True,
).exists():
# Add the user for workspace tracking
_ = ProjectPublicMember.objects.get_or_create(
project_id=project_deploy_board.project_id, member=request.user
)
issue_activity.delay(
type="issue_reaction.activity.created",
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(project_deploy_board.project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, anchor, issue_id, reaction_code):
project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project")
if not project_deploy_board.is_reactions_enabled:
return Response(
{"error": "Reactions are not enabled for this project board"},
status=status.HTTP_400_BAD_REQUEST,
)
issue_reaction = IssueReaction.objects.get(
workspace_id=project_deploy_board.workspace_id,
issue_id=issue_id,
reaction=reaction_code,
actor=request.user,
)
issue_activity.delay(
type="issue_reaction.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(project_deploy_board.project_id),
current_instance=json.dumps({"reaction": str(reaction_code), "identifier": str(issue_reaction.id)}),
epoch=int(timezone.now().timestamp()),
)
issue_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class CommentReactionPublicViewSet(BaseViewSet):
serializer_class = CommentReactionSerializer
model = CommentReaction
def get_queryset(self):
try:
project_deploy_board = DeployBoard.objects.get(anchor=self.kwargs.get("anchor"), entity_name="project")
if project_deploy_board.is_reactions_enabled:
return (
super()
.get_queryset()
.filter(workspace_id=project_deploy_board.workspace_id)
.filter(project_id=project_deploy_board.project_id)
.filter(comment_id=self.kwargs.get("comment_id"))
.order_by("-created_at")
.distinct()
)
return CommentReaction.objects.none()
except DeployBoard.DoesNotExist:
return CommentReaction.objects.none()
def create(self, request, anchor, comment_id):
project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project")
if not project_deploy_board.is_reactions_enabled:
return Response(
{"error": "Reactions are not enabled for this board"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = CommentReactionSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_deploy_board.project_id,
comment_id=comment_id,
actor=request.user,
)
if not ProjectMember.objects.filter(
project_id=project_deploy_board.project_id,
member=request.user,
is_active=True,
).exists():
# Add the user for workspace tracking
_ = ProjectPublicMember.objects.get_or_create(
project_id=project_deploy_board.project_id, member=request.user
)
issue_activity.delay(
type="comment_reaction.activity.created",
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, anchor, comment_id, reaction_code):
project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project")
if not project_deploy_board.is_reactions_enabled:
return Response(
{"error": "Reactions are not enabled for this board"},
status=status.HTTP_400_BAD_REQUEST,
)
comment_reaction = CommentReaction.objects.get(
project_id=project_deploy_board.project_id,
workspace_id=project_deploy_board.workspace_id,
comment_id=comment_id,
reaction=reaction_code,
actor=request.user,
)
issue_activity.delay(
type="comment_reaction.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(project_deploy_board.project_id),
current_instance=json.dumps(
{
"reaction": str(reaction_code),
"identifier": str(comment_reaction.id),
"comment_id": str(comment_id),
}
),
epoch=int(timezone.now().timestamp()),
)
comment_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class IssueVotePublicViewSet(BaseViewSet):
model = IssueVote
serializer_class = IssueVoteSerializer
def get_queryset(self):
try:
project_deploy_board = DeployBoard.objects.get(
workspace__slug=self.kwargs.get("anchor"), entity_name="project"
)
if project_deploy_board.is_votes_enabled:
return (
super()
.get_queryset()
.filter(issue_id=self.kwargs.get("issue_id"))
.filter(workspace_id=project_deploy_board.workspace_id)
.filter(project_id=project_deploy_board.project_id)
)
return IssueVote.objects.none()
except DeployBoard.DoesNotExist:
return IssueVote.objects.none()
def create(self, request, anchor, issue_id):
project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project")
issue_vote, _ = IssueVote.objects.get_or_create(
actor_id=request.user.id,
project_id=project_deploy_board.project_id,
issue_id=issue_id,
)
# Add the user for workspace tracking
if not ProjectMember.objects.filter(
project_id=project_deploy_board.project_id,
member=request.user,
is_active=True,
).exists():
_ = ProjectPublicMember.objects.get_or_create(
project_id=project_deploy_board.project_id, member=request.user
)
issue_vote.vote = request.data.get("vote", 1)
issue_vote.save()
issue_activity.delay(
type="issue_vote.activity.created",
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(project_deploy_board.project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
serializer = IssueVoteSerializer(issue_vote)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def destroy(self, request, anchor, issue_id):
project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project")
issue_vote = IssueVote.objects.get(
issue_id=issue_id,
actor_id=request.user.id,
project_id=project_deploy_board.project_id,
workspace_id=project_deploy_board.workspace_id,
)
issue_activity.delay(
type="issue_vote.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(project_deploy_board.project_id),
current_instance=json.dumps({"vote": str(issue_vote.vote), "identifier": str(issue_vote.id)}),
epoch=int(timezone.now().timestamp()),
)
issue_vote.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class IssueRetrievePublicEndpoint(BaseAPIView):
permission_classes = [AllowAny]
def get(self, request, anchor, issue_id):
deploy_board = DeployBoard.objects.get(anchor=anchor)
issue_queryset = (
Issue.issue_objects.filter(
pk=issue_id,
workspace__slug=deploy_board.workspace.slug,
project_id=deploy_board.project_id,
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1]
)
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=Q(~Q(labels__id__isnull=True) & Q(label_issue__deleted_at__isnull=True)),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=Q(
~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True)
& Q(issue_assignee__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__deleted_at__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("issue", "actor"),
)
)
.prefetch_related(Prefetch("votes", queryset=IssueVote.objects.select_related("actor")))
.annotate(
vote_items=ArrayAgg(
Case(
When(
votes__isnull=False,
votes__deleted_at__isnull=True,
then=JSONObject(
vote=F("votes__vote"),
actor_details=JSONObject(
id=F("votes__actor__id"),
first_name=F("votes__actor__first_name"),
last_name=F("votes__actor__last_name"),
avatar=F("votes__actor__avatar"),
avatar_url=Case(
When(
votes__actor__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
F("votes__actor__avatar_asset"),
Value("/"),
),
),
When(
votes__actor__avatar_asset__isnull=True,
then=F("votes__actor__avatar"),
),
default=Value(None),
output_field=CharField(),
),
display_name=F("votes__actor__display_name"),
),
),
),
default=None,
output_field=JSONField(),
),
filter=Case(
When(
votes__isnull=False,
votes__deleted_at__isnull=True,
then=True,
),
default=False,
output_field=JSONField(),
),
distinct=True,
),
reaction_items=ArrayAgg(
Case(
When(
issue_reactions__isnull=False,
issue_reactions__deleted_at__isnull=True,
then=JSONObject(
reaction=F("issue_reactions__reaction"),
actor_details=JSONObject(
id=F("issue_reactions__actor__id"),
first_name=F("issue_reactions__actor__first_name"),
last_name=F("issue_reactions__actor__last_name"),
avatar=F("issue_reactions__actor__avatar"),
avatar_url=Case(
When(
votes__actor__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
F("votes__actor__avatar_asset"),
Value("/"),
),
),
When(
votes__actor__avatar_asset__isnull=True,
then=F("votes__actor__avatar"),
),
default=Value(None),
output_field=CharField(),
),
display_name=F("issue_reactions__actor__display_name"),
),
),
),
default=None,
output_field=JSONField(),
),
filter=Case(
When(
issue_reactions__isnull=False,
issue_reactions__deleted_at__isnull=True,
then=True,
),
default=False,
output_field=JSONField(),
),
distinct=True,
),
)
.values(
"id",
"name",
"state_id",
"sort_order",
"description_json",
"description_html",
"description_stripped",
"description_binary",
"module_ids",
"label_ids",
"assignee_ids",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"created_by",
"state__group",
"vote_items",
"reaction_items",
)
).first()
return Response(issue_queryset, status=status.HTTP_200_OK)
@@ -0,0 +1,28 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
# Module imports
from .base import BaseAPIView
from plane.db.models import DeployBoard, Label
class ProjectLabelsEndpoint(BaseAPIView):
permission_classes = [AllowAny]
def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
if not deploy_board:
return Response({"error": "Invalid anchor"}, status=status.HTTP_404_NOT_FOUND)
labels = Label.objects.filter(
workspace__slug=deploy_board.workspace.slug,
project_id=deploy_board.project_id,
).values("id", "name", "color", "parent")
return Response(labels, status=status.HTTP_200_OK)
@@ -0,0 +1,32 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# third party
from rest_framework.permissions import AllowAny
from rest_framework import status
from rest_framework.response import Response
from plane.db.models import DeployBoard, Project
from .base import BaseAPIView
from plane.space.serializer.project import ProjectLiteSerializer
class ProjectMetaDataEndpoint(BaseAPIView):
permission_classes = [AllowAny]
def get(self, request, anchor):
try:
deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project")
except DeployBoard.DoesNotExist:
return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND)
try:
project_id = deploy_board.entity_identifier
project = Project.objects.get(id=project_id)
except Project.DoesNotExist:
return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND)
serializer = ProjectLiteSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -0,0 +1,28 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Third Party imports
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.db.models import DeployBoard, Module
class ProjectModulesEndpoint(BaseAPIView):
permission_classes = [AllowAny]
def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
if not deploy_board:
return Response({"error": "Invalid anchor"}, status=status.HTTP_404_NOT_FOUND)
modules = Module.objects.filter(
workspace__slug=deploy_board.workspace.slug,
project_id=deploy_board.project_id,
).values("id", "name")
return Response(modules, status=status.HTTP_200_OK)
@@ -0,0 +1,86 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Django imports
from django.db.models import Exists, OuterRef
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
# Module imports
from .base import BaseAPIView
from plane.app.serializers import DeployBoardSerializer
from plane.db.models import Project, DeployBoard, ProjectMember
class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView):
permission_classes = [AllowAny]
def get(self, request, anchor):
project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project")
serializer = DeployBoardSerializer(project_deploy_board)
return Response(serializer.data, status=status.HTTP_200_OK)
class WorkspaceProjectDeployBoardEndpoint(BaseAPIView):
permission_classes = [AllowAny]
def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(anchor=anchor, entity_name="project").values_list
projects = (
Project.objects.filter(workspace=deploy_board.workspace)
.annotate(
is_public=Exists(
DeployBoard.objects.filter(anchor=anchor, project_id=OuterRef("pk"), entity_name="project")
)
)
.filter(is_public=True)
).values(
"id",
"identifier",
"name",
"description",
"emoji",
"icon_prop",
"cover_image",
)
return Response(projects, status=status.HTTP_200_OK)
class WorkspaceProjectAnchorEndpoint(BaseAPIView):
permission_classes = [AllowAny]
def get(self, request, slug, project_id):
project_deploy_board = DeployBoard.objects.get(
workspace__slug=slug, project_id=project_id, entity_name="project"
)
serializer = DeployBoardSerializer(project_deploy_board)
return Response(serializer.data, status=status.HTTP_200_OK)
class ProjectMembersEndpoint(BaseAPIView):
permission_classes = [AllowAny]
def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
if not deploy_board:
return Response(
{"error": "Invalid anchor"},
status=status.HTTP_404_NOT_FOUND,
)
members = ProjectMember.objects.filter(
project=deploy_board.project,
workspace=deploy_board.workspace,
is_active=True,
).values(
"id",
"member",
"member__display_name",
"member__avatar",
)
return Response(members, status=status.HTTP_200_OK)
@@ -0,0 +1,32 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Django imports
from django.db.models import Q
# Third Party imports
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.db.models import DeployBoard, State
class ProjectStatesEndpoint(BaseAPIView):
permission_classes = [AllowAny]
def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
if not deploy_board:
return Response({"error": "Invalid anchor"}, status=status.HTTP_404_NOT_FOUND)
states = State.objects.filter(
~Q(name="Triage"),
workspace__slug=deploy_board.workspace.slug,
project_id=deploy_board.project_id,
).values("name", "group", "color", "id", "sequence")
return Response(states, status=status.HTTP_200_OK)