Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/plane/app/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
IssueFlatSerializer,
IssueStateSerializer,
IssueLinkSerializer,
IssueChecklistItemSerializer,
IssueIntakeSerializer,
IssueLiteSerializer,
IssueAttachmentSerializer,
Expand Down
39 changes: 39 additions & 0 deletions apps/api/plane/app/serializers/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
User,
Issue,
IssueActivity,
IssueChecklistItem,
IssueComment,
ProjectUserProperty,
IssueAssignee,
Expand Down Expand Up @@ -598,6 +599,44 @@ def update(self, instance, validated_data):
return super().update(instance, validated_data)


class IssueChecklistItemSerializer(BaseSerializer):
class Meta:
model = IssueChecklistItem
fields = [
"id",
"name",
"status",
"sort_order",
"completed_at",
"completed_by",
"issue",
"project",
"workspace",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
read_only_fields = [
"id",
"workspace",
"project",
"issue",
"completed_at",
"completed_by",
"created_at",
"updated_at",
"created_by",
"updated_by",
]

def validate_name(self, value):
value = (value or "").strip()
if not value:
raise serializers.ValidationError("Name cannot be empty.")
return value


class IssueLinkLiteSerializer(BaseSerializer):
class Meta:
model = IssueLink
Expand Down
17 changes: 17 additions & 0 deletions apps/api/plane/app/urls/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
BulkDeleteIssuesEndpoint,
SubIssuesEndpoint,
IssueLinkViewSet,
IssueChecklistItemViewSet,
IssueAttachmentEndpoint,
CommentReactionViewSet,
IssueActivityEndpoint,
Expand Down Expand Up @@ -123,6 +124,22 @@
),
name="project-issue-links",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/checklist-items/",
IssueChecklistItemViewSet.as_view({"get": "list", "post": "create"}),
name="project-issue-checklist-items",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/checklist-items/<uuid:pk>/",
IssueChecklistItemViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-issue-checklist-items",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/",
IssueAttachmentEndpoint.as_view(),
Expand Down
1 change: 1 addition & 0 deletions apps/api/plane/app/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@
from .issue.label import LabelViewSet, BulkCreateIssueLabelsEndpoint

from .issue.link import IssueLinkViewSet
from .issue.checklist import IssueChecklistItemViewSet

from .issue.relation import IssueRelationViewSet

Expand Down
170 changes: 170 additions & 0 deletions apps/api/plane/app/views/issue/checklist.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# 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.db.models import Max
from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder

# Third Party imports
from rest_framework.response import Response
from rest_framework import status

# Module imports
from .. import BaseViewSet
from plane.app.serializers import IssueChecklistItemSerializer
from plane.app.permissions import ROLE, ProjectEntityPermission
from plane.db.models import Issue, IssueChecklistItem, ProjectMember
from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.host import base_host

SORT_ORDER_STEP = 65535


class IssueChecklistItemViewSet(BaseViewSet):
permission_classes = [ProjectEntityPermission]

model = IssueChecklistItem
serializer_class = IssueChecklistItemSerializer

def get_queryset(self):
# SECURITY: ProjectEntityPermission only proves the caller is a member of
# the URL project_id — it does NOT prove issue_id lives in that project.
# Every lookup must keep workspace__slug + project_id + issue_id together,
# or a member of one project can read/write another project's checklist
# items in the same workspace. See the same note in
# plane/app/views/issue/sub_issue.py:38-44.
queryset = (
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"))
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
)

# SECURITY: a guest without guest_view_all_features may only see the
# checklist of a work item they created themselves, mirroring
# IssueViewSet.retrieve (views/issue/base.py:599-613). Child-entity
# viewsets otherwise gate only on project membership, which would let
# a guest read a checklist on a work item whose detail view they are
# forbidden from opening.
if ProjectMember.objects.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
member=self.request.user,
role=ROLE.GUEST.value,
is_active=True,
project__guest_view_all_features=False,
).exists():
queryset = queryset.filter(issue__created_by=self.request.user)

return queryset.order_by("sort_order", "created_at").distinct()

def create(self, request, slug, project_id, issue_id):
# SECURITY: bind the parent work item to the URL workspace + project so a
# cross-project issue_id 404s instead of silently attaching the item
# somewhere the caller does not expect.
if not Issue.objects.filter(pk=issue_id, project_id=project_id, workspace__slug=slug).exists():
return Response(
{"error": "The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)

serializer = IssueChecklistItemSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

if "sort_order" not in serializer.validated_data:
last_sort_order = IssueChecklistItem.objects.filter(
issue_id=issue_id, project_id=project_id, workspace__slug=slug
).aggregate(largest=Max("sort_order"))["largest"]
serializer.validated_data["sort_order"] = (
SORT_ORDER_STEP if last_sort_order is None else last_sort_order + SORT_ORDER_STEP
)

serializer.save(project_id=project_id, issue_id=issue_id)

issue_activity.delay(
type="checklist_item.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_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
origin=base_host(request=request, is_app=True),
# NOTE: no `notification=True` here. issue_activity defaults it to
# False; IssueLinkViewSet passes True, which would fan out an
# in-app + email notification to every subscriber on every
# checklist change. That is not wanted for a checkbox tick.
)

checklist_item = self.get_queryset().get(id=serializer.data.get("id"))
serializer = IssueChecklistItemSerializer(checklist_item)
return Response(serializer.data, status=status.HTTP_201_CREATED)

def partial_update(self, request, slug, project_id, issue_id, pk):
# SECURITY: resolve through get_queryset(), never a bare pk lookup, so
# this cannot drift out of sync with the scoping filter above.
checklist_item = self.get_queryset().filter(pk=pk).first()
if not checklist_item:
return Response(
{"error": "The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)

current_instance = json.dumps(IssueChecklistItemSerializer(checklist_item).data, cls=DjangoJSONEncoder)

serializer = IssueChecklistItemSerializer(checklist_item, data=request.data, partial=True)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

serializer.save()

issue_activity.delay(
type="checklist_item.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_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
origin=base_host(request=request, is_app=True),
)

checklist_item = self.get_queryset().get(id=serializer.data.get("id"))
serializer = IssueChecklistItemSerializer(checklist_item)
return Response(serializer.data, status=status.HTTP_200_OK)

def destroy(self, request, slug, project_id, issue_id, pk):
checklist_item = self.get_queryset().filter(pk=pk).first()
if not checklist_item:
return Response(
{"error": "The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)

current_instance = json.dumps(IssueChecklistItemSerializer(checklist_item).data, cls=DjangoJSONEncoder)

issue_activity.delay(
type="checklist_item.activity.deleted",
requested_data=json.dumps({"checklist_item_id": str(pk)}),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
origin=base_host(request=request, is_app=True),
)

checklist_item.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
113 changes: 113 additions & 0 deletions apps/api/plane/bgtasks/issue_activities_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -1014,6 +1014,116 @@ def delete_link_activity(
)


def create_checklist_item_activity(
requested_data,
current_instance,
issue_id,
project_id,
actor_id,
workspace_id,
issue_activities,
epoch,
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = json.loads(current_instance) if current_instance is not None else None

issue_activities.append(
IssueActivity(
issue_id=issue_id,
project_id=project_id,
workspace_id=workspace_id,
comment="added a checklist item",
verb="created",
actor_id=actor_id,
field="checklist_item",
new_value=requested_data.get("name", ""),
new_identifier=requested_data.get("id", None),
epoch=epoch,
)
)


def update_checklist_item_activity(
requested_data,
current_instance,
issue_id,
project_id,
workspace_id,
actor_id,
issue_activities,
epoch,
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = json.loads(current_instance) if current_instance is not None else None

# A drag-reorder PATCH only ever carries sort_order and must stay silent
# (spec FR-025); each of the two conditions below only fires when the
# field it checks is present AND actually different from before.
if "status" in requested_data and requested_data.get("status") != current_instance.get("status"):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project_id=project_id,
workspace_id=workspace_id,
comment="updated a checklist item",
verb="updated",
actor_id=actor_id,
field="checklist_item_status",
old_value=current_instance.get("status", ""),
new_value=requested_data.get("status", ""),
old_identifier=current_instance.get("id"),
new_identifier=current_instance.get("id"),
epoch=epoch,
)
)

if "name" in requested_data and requested_data.get("name") != current_instance.get("name"):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project_id=project_id,
workspace_id=workspace_id,
comment="renamed a checklist item",
verb="updated",
actor_id=actor_id,
field="checklist_item",
old_value=current_instance.get("name", ""),
new_value=requested_data.get("name", ""),
old_identifier=current_instance.get("id"),
new_identifier=current_instance.get("id"),
epoch=epoch,
)
)


def delete_checklist_item_activity(
requested_data,
current_instance,
issue_id,
project_id,
workspace_id,
actor_id,
issue_activities,
epoch,
):
current_instance = json.loads(current_instance) if current_instance is not None else None

issue_activities.append(
IssueActivity(
issue_id=issue_id,
project_id=project_id,
workspace_id=workspace_id,
comment="removed a checklist item",
verb="deleted",
actor_id=actor_id,
field="checklist_item",
old_value=current_instance.get("name", ""),
new_value="",
epoch=epoch,
)
)


def create_attachment_activity(
requested_data,
current_instance,
Expand Down Expand Up @@ -1551,6 +1661,9 @@ def issue_activity(
"link.activity.created": create_link_activity,
"link.activity.updated": update_link_activity,
"link.activity.deleted": delete_link_activity,
"checklist_item.activity.created": create_checklist_item_activity,
"checklist_item.activity.updated": update_checklist_item_activity,
"checklist_item.activity.deleted": delete_checklist_item_activity,
"attachment.activity.created": create_attachment_activity,
"attachment.activity.deleted": delete_attachment_activity,
"issue_relation.activity.created": create_issue_relation_activity,
Expand Down
Loading