Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7743e59fff | ||
|
|
e710f5b278 | ||
|
|
80bd1b6dcd | ||
|
|
b34c9ca04f | ||
|
|
ad16ea1440 | ||
|
|
addf3c4104 | ||
|
|
38cdf756a5 | ||
|
|
f94da68597 | ||
|
|
68aa2fe0b8 | ||
|
|
5fa9943b66 | ||
|
|
772b5c5719 | ||
|
|
d6657d5168 | ||
|
|
1b43efbc2a | ||
|
|
64781be7d2 | ||
|
|
59022b6beb | ||
|
|
f266cd8414 | ||
|
|
98b81d7ebb | ||
|
|
00807d2d62 | ||
|
|
9d757ccc6a | ||
|
|
a8c253acfe | ||
|
|
2b106cbd66 | ||
|
|
f9cca8e2cb | ||
|
|
ee176efae3 | ||
|
|
44a483f895 | ||
|
|
196baf099a | ||
|
|
1a9ebc8b68 | ||
|
|
97e662215a | ||
|
|
606e34ec81 | ||
|
|
a3019ebd46 | ||
|
|
9cfde896b3 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"description": "Admin UI for Plane",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "plane-api",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"description": "API server powering Plane's backend"
|
||||
|
||||
@@ -12,13 +12,7 @@ from django.db.models import (
|
||||
OuterRef,
|
||||
Q,
|
||||
Sum,
|
||||
FloatField,
|
||||
Case,
|
||||
When,
|
||||
Value,
|
||||
)
|
||||
from django.db.models.functions import Cast, Concat
|
||||
from django.db import models
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
@@ -47,7 +41,7 @@ from plane.db.models import (
|
||||
ProjectMember,
|
||||
UserFavorite,
|
||||
)
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
from plane.utils.cycle_transfer_issues import transfer_cycle_issues
|
||||
from plane.utils.host import base_host
|
||||
from .base import BaseAPIView
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
@@ -201,7 +195,9 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
|
||||
|
||||
# Current Cycle
|
||||
if cycle_view == "current":
|
||||
queryset = queryset.filter(start_date__lte=timezone.now(), end_date__gte=timezone.now())
|
||||
queryset = queryset.filter(
|
||||
start_date__lte=timezone.now(), end_date__gte=timezone.now()
|
||||
)
|
||||
data = CycleSerializer(
|
||||
queryset,
|
||||
many=True,
|
||||
@@ -258,7 +254,9 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
|
||||
|
||||
# Incomplete Cycles
|
||||
if cycle_view == "incomplete":
|
||||
queryset = queryset.filter(Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True))
|
||||
queryset = queryset.filter(
|
||||
Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True)
|
||||
)
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(queryset),
|
||||
@@ -304,11 +302,17 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
|
||||
Create a new development cycle with specified name, description, and date range.
|
||||
Supports external ID tracking for integration purposes.
|
||||
"""
|
||||
if (request.data.get("start_date", None) is None and request.data.get("end_date", None) is None) or (
|
||||
request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None
|
||||
if (
|
||||
request.data.get("start_date", None) is None
|
||||
and request.data.get("end_date", None) is None
|
||||
) or (
|
||||
request.data.get("start_date", None) is not None
|
||||
and request.data.get("end_date", None) is not None
|
||||
):
|
||||
|
||||
serializer = CycleCreateSerializer(data=request.data, context={"request": request})
|
||||
serializer = CycleCreateSerializer(
|
||||
data=request.data, context={"request": request}
|
||||
)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
@@ -351,7 +355,9 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
return Response(
|
||||
{"error": "Both start date and end date are either required or are to be null"},
|
||||
{
|
||||
"error": "Both start date and end date are either required or are to be null"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -499,7 +505,9 @@ class CycleDetailAPIEndpoint(BaseAPIView):
|
||||
"""
|
||||
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
|
||||
|
||||
current_instance = json.dumps(CycleSerializer(cycle).data, cls=DjangoJSONEncoder)
|
||||
current_instance = json.dumps(
|
||||
CycleSerializer(cycle).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
|
||||
if cycle.archived_at:
|
||||
return Response(
|
||||
@@ -512,14 +520,20 @@ class CycleDetailAPIEndpoint(BaseAPIView):
|
||||
if cycle.end_date is not None and cycle.end_date < timezone.now():
|
||||
if "sort_order" in request_data:
|
||||
# Can only change sort order
|
||||
request_data = {"sort_order": request_data.get("sort_order", cycle.sort_order)}
|
||||
request_data = {
|
||||
"sort_order": request_data.get("sort_order", cycle.sort_order)
|
||||
}
|
||||
else:
|
||||
return Response(
|
||||
{"error": "The Cycle has already been completed so it cannot be edited"},
|
||||
{
|
||||
"error": "The Cycle has already been completed so it cannot be edited"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = CycleUpdateSerializer(cycle, data=request.data, partial=True, context={"request": request})
|
||||
serializer = CycleUpdateSerializer(
|
||||
cycle, data=request.data, partial=True, context={"request": request}
|
||||
)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
@@ -527,7 +541,9 @@ class CycleDetailAPIEndpoint(BaseAPIView):
|
||||
and Cycle.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get("external_source", cycle.external_source),
|
||||
external_source=request.data.get(
|
||||
"external_source", cycle.external_source
|
||||
),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
@@ -584,7 +600,11 @@ class CycleDetailAPIEndpoint(BaseAPIView):
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
cycle_issues = list(CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list("issue", flat=True))
|
||||
cycle_issues = list(
|
||||
CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list(
|
||||
"issue", flat=True
|
||||
)
|
||||
)
|
||||
|
||||
issue_activity.delay(
|
||||
type="cycle.activity.deleted",
|
||||
@@ -604,7 +624,9 @@ class CycleDetailAPIEndpoint(BaseAPIView):
|
||||
# Delete the cycle
|
||||
cycle.delete()
|
||||
# Delete the user favorite cycle
|
||||
UserFavorite.objects.filter(entity_type="cycle", entity_identifier=pk, project_id=project_id).delete()
|
||||
UserFavorite.objects.filter(
|
||||
entity_type="cycle", entity_identifier=pk, project_id=project_id
|
||||
).delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@@ -742,7 +764,9 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(self.get_queryset()),
|
||||
on_results=lambda cycles: CycleSerializer(cycles, many=True, fields=self.fields, expand=self.expand).data,
|
||||
on_results=lambda cycles: CycleSerializer(
|
||||
cycles, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
)
|
||||
|
||||
@cycle_docs(
|
||||
@@ -761,7 +785,9 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
|
||||
Move a completed cycle to archived status for historical tracking.
|
||||
Only cycles that have ended can be archived.
|
||||
"""
|
||||
cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug)
|
||||
cycle = Cycle.objects.get(
|
||||
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
if cycle.end_date >= timezone.now():
|
||||
return Response(
|
||||
{"error": "Only completed cycles can be archived"},
|
||||
@@ -792,7 +818,9 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
|
||||
Restore an archived cycle to active status, making it available for regular use.
|
||||
The cycle will reappear in active cycle lists.
|
||||
"""
|
||||
cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug)
|
||||
cycle = Cycle.objects.get(
|
||||
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
cycle.archived_at = None
|
||||
cycle.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -855,7 +883,9 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
# List
|
||||
order_by = request.GET.get("order_by", "created_at")
|
||||
issues = (
|
||||
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True)
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
@@ -892,7 +922,9 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(issues),
|
||||
on_results=lambda issues: IssueSerializer(issues, many=True, fields=self.fields, expand=self.expand).data,
|
||||
on_results=lambda issues: IssueSerializer(
|
||||
issues, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
)
|
||||
|
||||
@cycle_docs(
|
||||
@@ -922,10 +954,13 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
|
||||
if not issues:
|
||||
return Response(
|
||||
{"error": "Work items are required", "code": "MISSING_WORK_ITEMS"}, status=status.HTTP_400_BAD_REQUEST
|
||||
{"error": "Work items are required", "code": "MISSING_WORK_ITEMS"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=cycle_id)
|
||||
cycle = Cycle.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
||||
)
|
||||
|
||||
if cycle.end_date is not None and cycle.end_date < timezone.now():
|
||||
return Response(
|
||||
@@ -937,9 +972,13 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
# Get all CycleWorkItems already created
|
||||
cycle_issues = list(CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues))
|
||||
cycle_issues = list(
|
||||
CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues)
|
||||
)
|
||||
existing_issues = [
|
||||
str(cycle_issue.issue_id) for cycle_issue in cycle_issues if str(cycle_issue.issue_id) in issues
|
||||
str(cycle_issue.issue_id)
|
||||
for cycle_issue in cycle_issues
|
||||
if str(cycle_issue.issue_id) in issues
|
||||
]
|
||||
new_issues = list(set(issues) - set(existing_issues))
|
||||
|
||||
@@ -990,7 +1029,9 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
current_instance=json.dumps(
|
||||
{
|
||||
"updated_cycle_issues": update_cycle_issue_activity,
|
||||
"created_cycle_issues": serializers.serialize("json", created_records),
|
||||
"created_cycle_issues": serializers.serialize(
|
||||
"json", created_records
|
||||
),
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
@@ -1066,7 +1107,9 @@ class CycleIssueDetailAPIEndpoint(BaseAPIView):
|
||||
cycle_id=cycle_id,
|
||||
issue_id=issue_id,
|
||||
)
|
||||
serializer = CycleIssueSerializer(cycle_issue, fields=self.fields, expand=self.expand)
|
||||
serializer = CycleIssueSerializer(
|
||||
cycle_issue, fields=self.fields, expand=self.expand
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@cycle_docs(
|
||||
@@ -1171,406 +1214,34 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
||||
{"error": "New Cycle Id is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
new_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=new_cycle_id).first()
|
||||
|
||||
old_cycle = (
|
||||
Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle",
|
||||
filter=Q(
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="completed",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="cancelled",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="started",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="unstarted",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="backlog",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
old_cycle = old_cycle.first()
|
||||
|
||||
estimate_type = Project.objects.filter(
|
||||
|
||||
old_cycle = Cycle.objects.get(
|
||||
workspace__slug=slug,
|
||||
pk=project_id,
|
||||
estimate__isnull=False,
|
||||
estimate__type="points",
|
||||
).exists()
|
||||
|
||||
if estimate_type:
|
||||
assignee_estimate_data = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.annotate(
|
||||
avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(
|
||||
assignees__avatar_asset__isnull=True,
|
||||
then="assignees__avatar",
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar", "avatar_url")
|
||||
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("display_name")
|
||||
)
|
||||
# assignee distribution serialization
|
||||
assignee_estimate_distribution = [
|
||||
{
|
||||
"display_name": item["display_name"],
|
||||
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
|
||||
"avatar": item.get("avatar", None),
|
||||
"avatar_url": item.get("avatar_url", None),
|
||||
"total_estimates": item["total_estimates"],
|
||||
"completed_estimates": item["completed_estimates"],
|
||||
"pending_estimates": item["pending_estimates"],
|
||||
}
|
||||
for item in assignee_estimate_data
|
||||
]
|
||||
|
||||
label_distribution_data = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
|
||||
estimate_completion_chart = burndown_plot(
|
||||
queryset=old_cycle,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type="points",
|
||||
cycle_id=cycle_id,
|
||||
)
|
||||
# Label distribution serialization
|
||||
label_estimate_distribution = [
|
||||
{
|
||||
"label_name": item["label_name"],
|
||||
"color": item["color"],
|
||||
"label_id": (str(item["label_id"]) if item["label_id"] else None),
|
||||
"total_estimates": item["total_estimates"],
|
||||
"completed_estimates": item["completed_estimates"],
|
||||
"pending_estimates": item["pending_estimates"],
|
||||
}
|
||||
for item in label_distribution_data
|
||||
]
|
||||
|
||||
# Get the assignee distribution
|
||||
assignee_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.annotate(
|
||||
avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(assignees__avatar_asset__isnull=True, then="assignees__avatar"),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar_url")
|
||||
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("display_name")
|
||||
)
|
||||
# assignee distribution serialized
|
||||
assignee_distribution_data = [
|
||||
{
|
||||
"display_name": item["display_name"],
|
||||
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
|
||||
"avatar": item.get("avatar", None),
|
||||
"avatar_url": item.get("avatar_url", None),
|
||||
"total_issues": item["total_issues"],
|
||||
"completed_issues": item["completed_issues"],
|
||||
"pending_issues": item["pending_issues"],
|
||||
}
|
||||
for item in assignee_distribution
|
||||
]
|
||||
|
||||
# Get the label distribution
|
||||
label_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
|
||||
# Label distribution serilization
|
||||
label_distribution_data = [
|
||||
{
|
||||
"label_name": item["label_name"],
|
||||
"color": item["color"],
|
||||
"label_id": (str(item["label_id"]) if item["label_id"] else None),
|
||||
"total_issues": item["total_issues"],
|
||||
"completed_issues": item["completed_issues"],
|
||||
"pending_issues": item["pending_issues"],
|
||||
}
|
||||
for item in label_distribution
|
||||
]
|
||||
|
||||
# Pass the new_cycle queryset to burndown_plot
|
||||
completion_chart = burndown_plot(
|
||||
queryset=old_cycle,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type="issues",
|
||||
cycle_id=cycle_id,
|
||||
pk=cycle_id,
|
||||
)
|
||||
|
||||
current_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id).first()
|
||||
|
||||
current_cycle.progress_snapshot = {
|
||||
"total_issues": old_cycle.total_issues,
|
||||
"completed_issues": old_cycle.completed_issues,
|
||||
"cancelled_issues": old_cycle.cancelled_issues,
|
||||
"started_issues": old_cycle.started_issues,
|
||||
"unstarted_issues": old_cycle.unstarted_issues,
|
||||
"backlog_issues": old_cycle.backlog_issues,
|
||||
"distribution": {
|
||||
"labels": label_distribution_data,
|
||||
"assignees": assignee_distribution_data,
|
||||
"completion_chart": completion_chart,
|
||||
},
|
||||
"estimate_distribution": (
|
||||
{}
|
||||
if not estimate_type
|
||||
else {
|
||||
"labels": label_estimate_distribution,
|
||||
"assignees": assignee_estimate_distribution,
|
||||
"completion_chart": estimate_completion_chart,
|
||||
}
|
||||
),
|
||||
}
|
||||
current_cycle.save(update_fields=["progress_snapshot"])
|
||||
|
||||
if new_cycle.end_date is not None and new_cycle.end_date < timezone.now():
|
||||
# transfer work items only when cycle is completed (passed the end data)
|
||||
if old_cycle.end_date is not None and old_cycle.end_date < timezone.now():
|
||||
return Response(
|
||||
{"error": "The cycle where the issues are transferred is already completed"},
|
||||
{"error": "The old cycle is not completed yet"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
cycle_issues = CycleIssue.objects.filter(
|
||||
cycle_id=cycle_id,
|
||||
# Call the utility function to handle the transfer
|
||||
result = transfer_cycle_issues(
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
issue__state__group__in=["backlog", "unstarted", "started"],
|
||||
cycle_id=cycle_id,
|
||||
new_cycle_id=new_cycle_id,
|
||||
request=request,
|
||||
user_id=self.request.user.id,
|
||||
)
|
||||
|
||||
updated_cycles = []
|
||||
update_cycle_issue_activity = []
|
||||
for cycle_issue in cycle_issues:
|
||||
cycle_issue.cycle_id = new_cycle_id
|
||||
updated_cycles.append(cycle_issue)
|
||||
update_cycle_issue_activity.append(
|
||||
{
|
||||
"old_cycle_id": str(cycle_id),
|
||||
"new_cycle_id": str(new_cycle_id),
|
||||
"issue_id": str(cycle_issue.issue_id),
|
||||
}
|
||||
# Handle the result
|
||||
if result.get("success"):
|
||||
return Response({"message": "Success"}, status=status.HTTP_200_OK)
|
||||
else:
|
||||
return Response(
|
||||
{"error": result.get("error")},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
cycle_issues = CycleIssue.objects.bulk_update(updated_cycles, ["cycle_id"], batch_size=100)
|
||||
|
||||
# Capture Issue Activity
|
||||
issue_activity.delay(
|
||||
type="cycle.activity.created",
|
||||
requested_data=json.dumps({"cycles_list": []}),
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=None,
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=json.dumps(
|
||||
{
|
||||
"updated_cycle_issues": update_cycle_issue_activity,
|
||||
"created_cycle_issues": "[]",
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
|
||||
return Response({"message": "Success"}, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -17,6 +17,7 @@ from .views import urlpatterns as view_urls
|
||||
from .webhook import urlpatterns as webhook_urls
|
||||
from .workspace import urlpatterns as workspace_urls
|
||||
from .timezone import urlpatterns as timezone_urls
|
||||
from .exporter import urlpatterns as exporter_urls
|
||||
|
||||
urlpatterns = [
|
||||
*analytic_urls,
|
||||
@@ -38,4 +39,5 @@ urlpatterns = [
|
||||
*api_urls,
|
||||
*webhook_urls,
|
||||
*timezone_urls,
|
||||
*exporter_urls,
|
||||
]
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.urls import path
|
||||
|
||||
from plane.app.views import ExportIssuesEndpoint
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/export-issues/",
|
||||
ExportIssuesEndpoint.as_view(),
|
||||
name="export-issues",
|
||||
),
|
||||
]
|
||||
@@ -7,7 +7,6 @@ from plane.app.views import (
|
||||
IssueLinkViewSet,
|
||||
IssueAttachmentEndpoint,
|
||||
CommentReactionViewSet,
|
||||
ExportIssuesEndpoint,
|
||||
IssueActivityEndpoint,
|
||||
IssueArchiveViewSet,
|
||||
IssueCommentViewSet,
|
||||
@@ -141,12 +140,6 @@ urlpatterns = [
|
||||
IssueAttachmentV2Endpoint.as_view(),
|
||||
name="project-issue-attachments",
|
||||
),
|
||||
## Export Issues
|
||||
path(
|
||||
"workspaces/<str:slug>/export-issues/",
|
||||
ExportIssuesEndpoint.as_view(),
|
||||
name="export-issues",
|
||||
),
|
||||
## End Issues
|
||||
## Issue Activity
|
||||
path(
|
||||
|
||||
@@ -51,6 +51,7 @@ from plane.db.models import (
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from plane.utils.host import base_host
|
||||
from plane.utils.cycle_transfer_issues import transfer_cycle_issues
|
||||
from .. import BaseAPIView, BaseViewSet
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
from plane.utils.timezone_converter import convert_to_utc, user_timezone_converter
|
||||
@@ -96,7 +97,9 @@ class CycleViewSet(BaseViewSet):
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_cycle__issue__assignees",
|
||||
queryset=User.objects.only("avatar_asset", "first_name", "id").distinct(),
|
||||
queryset=User.objects.only(
|
||||
"avatar_asset", "first_name", "id"
|
||||
).distinct(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
@@ -147,7 +150,8 @@ class CycleViewSet(BaseViewSet):
|
||||
.annotate(
|
||||
status=Case(
|
||||
When(
|
||||
Q(start_date__lte=current_time_in_utc) & Q(end_date__gte=current_time_in_utc),
|
||||
Q(start_date__lte=current_time_in_utc)
|
||||
& Q(end_date__gte=current_time_in_utc),
|
||||
then=Value("CURRENT"),
|
||||
),
|
||||
When(start_date__gt=current_time_in_utc, then=Value("UPCOMING")),
|
||||
@@ -166,7 +170,11 @@ class CycleViewSet(BaseViewSet):
|
||||
"issue_cycle__issue__assignees__id",
|
||||
distinct=True,
|
||||
filter=~Q(issue_cycle__issue__assignees__id__isnull=True)
|
||||
& (Q(issue_cycle__issue__issue_assignee__deleted_at__isnull=True)),
|
||||
& (
|
||||
Q(
|
||||
issue_cycle__issue__issue_assignee__deleted_at__isnull=True
|
||||
)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
)
|
||||
@@ -197,7 +205,9 @@ class CycleViewSet(BaseViewSet):
|
||||
|
||||
# Current Cycle
|
||||
if cycle_view == "current":
|
||||
queryset = queryset.filter(start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc)
|
||||
queryset = queryset.filter(
|
||||
start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc
|
||||
)
|
||||
|
||||
data = queryset.values(
|
||||
# necessary fields
|
||||
@@ -264,10 +274,16 @@ class CycleViewSet(BaseViewSet):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def create(self, request, slug, project_id):
|
||||
if (request.data.get("start_date", None) is None and request.data.get("end_date", None) is None) or (
|
||||
request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None
|
||||
if (
|
||||
request.data.get("start_date", None) is None
|
||||
and request.data.get("end_date", None) is None
|
||||
) or (
|
||||
request.data.get("start_date", None) is not None
|
||||
and request.data.get("end_date", None) is not None
|
||||
):
|
||||
serializer = CycleWriteSerializer(data=request.data, context={"project_id": project_id})
|
||||
serializer = CycleWriteSerializer(
|
||||
data=request.data, context={"project_id": project_id}
|
||||
)
|
||||
if serializer.is_valid():
|
||||
serializer.save(project_id=project_id, owned_by=request.user)
|
||||
cycle = (
|
||||
@@ -307,7 +323,9 @@ class CycleViewSet(BaseViewSet):
|
||||
project_timezone = project.timezone
|
||||
|
||||
datetime_fields = ["start_date", "end_date"]
|
||||
cycle = user_timezone_converter(cycle, datetime_fields, project_timezone)
|
||||
cycle = user_timezone_converter(
|
||||
cycle, datetime_fields, project_timezone
|
||||
)
|
||||
|
||||
# Send the model activity
|
||||
model_activity.delay(
|
||||
@@ -323,13 +341,17 @@ class CycleViewSet(BaseViewSet):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
return Response(
|
||||
{"error": "Both start date and end date are either required or are to be null"},
|
||||
{
|
||||
"error": "Both start date and end date are either required or are to be null"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
queryset = self.get_queryset().filter(workspace__slug=slug, project_id=project_id, pk=pk)
|
||||
queryset = self.get_queryset().filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
cycle = queryset.first()
|
||||
if cycle.archived_at:
|
||||
return Response(
|
||||
@@ -337,21 +359,29 @@ class CycleViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
current_instance = json.dumps(CycleSerializer(cycle).data, cls=DjangoJSONEncoder)
|
||||
current_instance = json.dumps(
|
||||
CycleSerializer(cycle).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
|
||||
request_data = request.data
|
||||
|
||||
if cycle.end_date is not None and cycle.end_date < timezone.now():
|
||||
if "sort_order" in request_data:
|
||||
# Can only change sort order for a completed cycle``
|
||||
request_data = {"sort_order": request_data.get("sort_order", cycle.sort_order)}
|
||||
request_data = {
|
||||
"sort_order": request_data.get("sort_order", cycle.sort_order)
|
||||
}
|
||||
else:
|
||||
return Response(
|
||||
{"error": "The Cycle has already been completed so it cannot be edited"},
|
||||
{
|
||||
"error": "The Cycle has already been completed so it cannot be edited"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = CycleWriteSerializer(cycle, data=request.data, partial=True, context={"project_id": project_id})
|
||||
serializer = CycleWriteSerializer(
|
||||
cycle, data=request.data, partial=True, context={"project_id": project_id}
|
||||
)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
cycle = queryset.values(
|
||||
@@ -451,7 +481,9 @@ class CycleViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
if data is None:
|
||||
return Response({"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
return Response(
|
||||
{"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
queryset = queryset.first()
|
||||
# Fetch the project timezone
|
||||
@@ -473,7 +505,11 @@ class CycleViewSet(BaseViewSet):
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
|
||||
|
||||
cycle_issues = list(CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list("issue", flat=True))
|
||||
cycle_issues = list(
|
||||
CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list(
|
||||
"issue", flat=True
|
||||
)
|
||||
)
|
||||
|
||||
issue_activity.delay(
|
||||
type="cycle.activity.deleted",
|
||||
@@ -524,7 +560,9 @@ class CycleDateCheckEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
start_date = convert_to_utc(date=str(start_date), project_id=project_id, is_start_date=True)
|
||||
start_date = convert_to_utc(
|
||||
date=str(start_date), project_id=project_id, is_start_date=True
|
||||
)
|
||||
end_date = convert_to_utc(
|
||||
date=str(end_date),
|
||||
project_id=project_id,
|
||||
@@ -597,409 +635,23 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
new_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=new_cycle_id).first()
|
||||
|
||||
old_cycle = (
|
||||
Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle",
|
||||
filter=Q(
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="completed",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="cancelled",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="started",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="unstarted",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="backlog",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
old_cycle = old_cycle.first()
|
||||
|
||||
estimate_type = Project.objects.filter(
|
||||
workspace__slug=slug,
|
||||
pk=project_id,
|
||||
estimate__isnull=False,
|
||||
estimate__type="points",
|
||||
).exists()
|
||||
|
||||
if estimate_type:
|
||||
assignee_estimate_data = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(
|
||||
avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(
|
||||
assignees__avatar_asset__isnull=True,
|
||||
then="assignees__avatar",
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar_url")
|
||||
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("display_name")
|
||||
)
|
||||
# assignee distribution serialization
|
||||
assignee_estimate_distribution = [
|
||||
{
|
||||
"display_name": item["display_name"],
|
||||
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
|
||||
"avatar": item.get("avatar"),
|
||||
"avatar_url": item.get("avatar_url"),
|
||||
"total_estimates": item["total_estimates"],
|
||||
"completed_estimates": item["completed_estimates"],
|
||||
"pending_estimates": item["pending_estimates"],
|
||||
}
|
||||
for item in assignee_estimate_data
|
||||
]
|
||||
|
||||
label_distribution_data = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
|
||||
estimate_completion_chart = burndown_plot(
|
||||
queryset=old_cycle,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type="points",
|
||||
cycle_id=cycle_id,
|
||||
)
|
||||
# Label distribution serialization
|
||||
label_estimate_distribution = [
|
||||
{
|
||||
"label_name": item["label_name"],
|
||||
"color": item["color"],
|
||||
"label_id": (str(item["label_id"]) if item["label_id"] else None),
|
||||
"total_estimates": item["total_estimates"],
|
||||
"completed_estimates": item["completed_estimates"],
|
||||
"pending_estimates": item["pending_estimates"],
|
||||
}
|
||||
for item in label_distribution_data
|
||||
]
|
||||
|
||||
# Get the assignee distribution
|
||||
assignee_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(
|
||||
avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(assignees__avatar_asset__isnull=True, then="assignees__avatar"),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar_url")
|
||||
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("display_name")
|
||||
)
|
||||
# assignee distribution serialized
|
||||
assignee_distribution_data = [
|
||||
{
|
||||
"display_name": item["display_name"],
|
||||
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
|
||||
"avatar": item.get("avatar"),
|
||||
"avatar_url": item.get("avatar_url"),
|
||||
"total_issues": item["total_issues"],
|
||||
"completed_issues": item["completed_issues"],
|
||||
"pending_issues": item["pending_issues"],
|
||||
}
|
||||
for item in assignee_distribution
|
||||
]
|
||||
|
||||
# Get the label distribution
|
||||
label_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
|
||||
# Label distribution serilization
|
||||
label_distribution_data = [
|
||||
{
|
||||
"label_name": item["label_name"],
|
||||
"color": item["color"],
|
||||
"label_id": (str(item["label_id"]) if item["label_id"] else None),
|
||||
"total_issues": item["total_issues"],
|
||||
"completed_issues": item["completed_issues"],
|
||||
"pending_issues": item["pending_issues"],
|
||||
}
|
||||
for item in label_distribution
|
||||
]
|
||||
|
||||
# Pass the new_cycle queryset to burndown_plot
|
||||
completion_chart = burndown_plot(
|
||||
queryset=old_cycle,
|
||||
# Transfer cycle issues and create progress snapshot
|
||||
result = transfer_cycle_issues(
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type="issues",
|
||||
cycle_id=cycle_id,
|
||||
new_cycle_id=new_cycle_id,
|
||||
request=request,
|
||||
user_id=request.user.id,
|
||||
)
|
||||
|
||||
current_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id).first()
|
||||
|
||||
current_cycle.progress_snapshot = {
|
||||
"total_issues": old_cycle.total_issues,
|
||||
"completed_issues": old_cycle.completed_issues,
|
||||
"cancelled_issues": old_cycle.cancelled_issues,
|
||||
"started_issues": old_cycle.started_issues,
|
||||
"unstarted_issues": old_cycle.unstarted_issues,
|
||||
"backlog_issues": old_cycle.backlog_issues,
|
||||
"distribution": {
|
||||
"labels": label_distribution_data,
|
||||
"assignees": assignee_distribution_data,
|
||||
"completion_chart": completion_chart,
|
||||
},
|
||||
"estimate_distribution": (
|
||||
{}
|
||||
if not estimate_type
|
||||
else {
|
||||
"labels": label_estimate_distribution,
|
||||
"assignees": assignee_estimate_distribution,
|
||||
"completion_chart": estimate_completion_chart,
|
||||
}
|
||||
),
|
||||
}
|
||||
current_cycle.save(update_fields=["progress_snapshot"])
|
||||
|
||||
if new_cycle.end_date is not None and new_cycle.end_date < timezone.now():
|
||||
# Handle error response
|
||||
if result.get("error"):
|
||||
return Response(
|
||||
{"error": "The cycle where the issues are transferred is already completed"},
|
||||
{"error": result["error"]},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
cycle_issues = CycleIssue.objects.filter(
|
||||
cycle_id=cycle_id,
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
issue__state__group__in=["backlog", "unstarted", "started"],
|
||||
)
|
||||
|
||||
updated_cycles = []
|
||||
update_cycle_issue_activity = []
|
||||
for cycle_issue in cycle_issues:
|
||||
cycle_issue.cycle_id = new_cycle_id
|
||||
updated_cycles.append(cycle_issue)
|
||||
update_cycle_issue_activity.append(
|
||||
{
|
||||
"old_cycle_id": str(cycle_id),
|
||||
"new_cycle_id": str(new_cycle_id),
|
||||
"issue_id": str(cycle_issue.issue_id),
|
||||
}
|
||||
)
|
||||
|
||||
cycle_issues = CycleIssue.objects.bulk_update(updated_cycles, ["cycle_id"], batch_size=100)
|
||||
|
||||
# Capture Issue Activity
|
||||
issue_activity.delay(
|
||||
type="cycle.activity.created",
|
||||
requested_data=json.dumps({"cycles_list": []}),
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=None,
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=json.dumps(
|
||||
{
|
||||
"updated_cycle_issues": update_cycle_issue_activity,
|
||||
"created_cycle_issues": "[]",
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
|
||||
return Response({"message": "Success"}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@@ -1014,8 +666,12 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
cycle_properties.filters = request.data.get("filters", cycle_properties.filters)
|
||||
cycle_properties.rich_filters = request.data.get("rich_filters", cycle_properties.rich_filters)
|
||||
cycle_properties.display_filters = request.data.get("display_filters", cycle_properties.display_filters)
|
||||
cycle_properties.rich_filters = request.data.get(
|
||||
"rich_filters", cycle_properties.rich_filters
|
||||
)
|
||||
cycle_properties.display_filters = request.data.get(
|
||||
"display_filters", cycle_properties.display_filters
|
||||
)
|
||||
cycle_properties.display_properties = request.data.get(
|
||||
"display_properties", cycle_properties.display_properties
|
||||
)
|
||||
@@ -1039,9 +695,13 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
|
||||
class CycleProgressEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id, cycle_id):
|
||||
cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, id=cycle_id).first()
|
||||
cycle = Cycle.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, id=cycle_id
|
||||
).first()
|
||||
if not cycle:
|
||||
return Response({"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
return Response(
|
||||
{"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
aggregate_estimates = (
|
||||
Issue.issue_objects.filter(
|
||||
estimate_point__estimate__type="points",
|
||||
@@ -1087,7 +747,9 @@ class CycleProgressEndpoint(BaseAPIView):
|
||||
output_field=FloatField(),
|
||||
)
|
||||
),
|
||||
total_estimate_points=Sum("value_as_float", default=Value(0), output_field=FloatField()),
|
||||
total_estimate_points=Sum(
|
||||
"value_as_float", default=Value(0), output_field=FloatField()
|
||||
),
|
||||
)
|
||||
)
|
||||
if cycle.progress_snapshot:
|
||||
@@ -1147,11 +809,22 @@ class CycleProgressEndpoint(BaseAPIView):
|
||||
|
||||
return Response(
|
||||
{
|
||||
"backlog_estimate_points": aggregate_estimates["backlog_estimate_point"] or 0,
|
||||
"unstarted_estimate_points": aggregate_estimates["unstarted_estimate_point"] or 0,
|
||||
"started_estimate_points": aggregate_estimates["started_estimate_point"] or 0,
|
||||
"cancelled_estimate_points": aggregate_estimates["cancelled_estimate_point"] or 0,
|
||||
"completed_estimate_points": aggregate_estimates["completed_estimate_points"] or 0,
|
||||
"backlog_estimate_points": aggregate_estimates["backlog_estimate_point"]
|
||||
or 0,
|
||||
"unstarted_estimate_points": aggregate_estimates[
|
||||
"unstarted_estimate_point"
|
||||
]
|
||||
or 0,
|
||||
"started_estimate_points": aggregate_estimates["started_estimate_point"]
|
||||
or 0,
|
||||
"cancelled_estimate_points": aggregate_estimates[
|
||||
"cancelled_estimate_point"
|
||||
]
|
||||
or 0,
|
||||
"completed_estimate_points": aggregate_estimates[
|
||||
"completed_estimate_points"
|
||||
]
|
||||
or 0,
|
||||
"total_estimate_points": aggregate_estimates["total_estimate_points"],
|
||||
"backlog_issues": backlog_issues,
|
||||
"total_issues": total_issues,
|
||||
@@ -1169,7 +842,9 @@ class CycleAnalyticsEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, project_id, cycle_id):
|
||||
analytic_type = request.GET.get("type", "issues")
|
||||
cycle = (
|
||||
Cycle.objects.filter(workspace__slug=slug, project_id=project_id, id=cycle_id)
|
||||
Cycle.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, id=cycle_id
|
||||
)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
@@ -1252,7 +927,9 @@ class CycleAnalyticsEndpoint(BaseAPIView):
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar_url")
|
||||
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
|
||||
.annotate(
|
||||
total_estimates=Sum(Cast("estimate_point__value", FloatField()))
|
||||
)
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
@@ -1287,7 +964,9 @@ class CycleAnalyticsEndpoint(BaseAPIView):
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
|
||||
.annotate(
|
||||
total_estimates=Sum(Cast("estimate_point__value", FloatField()))
|
||||
)
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
@@ -1389,7 +1068,11 @@ class CycleAnalyticsEndpoint(BaseAPIView):
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(total_issues=Count("label_id", filter=Q(archived_at__isnull=True, is_draft=False)))
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"label_id", filter=Q(archived_at__isnull=True, is_draft=False)
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"label_id",
|
||||
|
||||
@@ -137,7 +137,11 @@ class PageViewSet(BaseViewSet):
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
# capture the page transaction
|
||||
page_transaction.delay(request.data, None, serializer.data["id"])
|
||||
page_transaction.delay(
|
||||
new_description_html=request.data.get("description_html", "<p></p>"),
|
||||
old_description_html=None,
|
||||
page_id=serializer.data["id"],
|
||||
)
|
||||
page = self.get_queryset().get(pk=serializer.data["id"])
|
||||
serializer = PageDetailSerializer(page)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
@@ -168,11 +172,8 @@ class PageViewSet(BaseViewSet):
|
||||
# capture the page transaction
|
||||
if request.data.get("description_html"):
|
||||
page_transaction.delay(
|
||||
new_value=request.data,
|
||||
old_value=json.dumps(
|
||||
{"description_html": page_description},
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
new_description_html=request.data.get("description_html", "<p></p>"),
|
||||
old_description_html=page_description,
|
||||
page_id=page_id,
|
||||
)
|
||||
|
||||
@@ -504,7 +505,11 @@ class PagesDescriptionViewSet(BaseViewSet):
|
||||
if serializer.is_valid():
|
||||
# Capture the page transaction
|
||||
if request.data.get("description_html"):
|
||||
page_transaction.delay(new_value=request.data, old_value=existing_instance, page_id=page_id)
|
||||
page_transaction.delay(
|
||||
new_description_html=request.data.get("description_html", "<p></p>"),
|
||||
old_description_html=page.description_html,
|
||||
page_id=page_id,
|
||||
)
|
||||
|
||||
# Update the page using serializer
|
||||
updated_page = serializer.save()
|
||||
@@ -550,7 +555,11 @@ class PageDuplicateEndpoint(BaseAPIView):
|
||||
updated_by_id=page.updated_by_id,
|
||||
)
|
||||
|
||||
page_transaction.delay({"description_html": page.description_html}, None, page.id)
|
||||
page_transaction.delay(
|
||||
new_description_html=page.description_html,
|
||||
old_description_html=None,
|
||||
page_id=page.id,
|
||||
)
|
||||
|
||||
# Copy the s3 objects uploaded in the page
|
||||
copy_s3_objects_of_description_and_assets.delay(
|
||||
|
||||
@@ -410,7 +410,8 @@ def get_webhook_logs_queryset():
|
||||
"response_headers",
|
||||
"retry_count",
|
||||
)
|
||||
.iterator(chunk_size=BATCH_SIZE)
|
||||
.order_by("created_at")
|
||||
.iterator(chunk_size=100)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Python imports
|
||||
import json
|
||||
import logging
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
@@ -7,72 +7,134 @@ from django.utils import timezone
|
||||
# Third-party imports
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Page, PageLog
|
||||
# App imports
|
||||
from celery import shared_task
|
||||
from plane.db.models import Page, PageLog
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
logger = logging.getLogger("plane.worker")
|
||||
|
||||
def extract_components(value, tag):
|
||||
COMPONENT_MAP = {
|
||||
"mention-component": {
|
||||
"attributes": ["id", "entity_identifier", "entity_name", "entity_type"],
|
||||
"extract": lambda m: {
|
||||
"entity_name": m.get("entity_name"),
|
||||
"entity_type": None,
|
||||
"entity_identifier": m.get("entity_identifier"),
|
||||
},
|
||||
},
|
||||
"image-component": {
|
||||
"attributes": ["id", "src"],
|
||||
"extract": lambda m: {
|
||||
"entity_name": "image",
|
||||
"entity_type": None,
|
||||
"entity_identifier": m.get("src"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
component_map = {
|
||||
**COMPONENT_MAP,
|
||||
}
|
||||
|
||||
|
||||
def extract_all_components(description_html):
|
||||
"""
|
||||
Extracts all component types from the HTML value in a single pass.
|
||||
Returns a dict mapping component_type -> list of extracted entities.
|
||||
"""
|
||||
try:
|
||||
mentions = []
|
||||
html = value.get("description_html")
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
mention_tags = soup.find_all(tag)
|
||||
if not description_html:
|
||||
return {component: [] for component in component_map.keys()}
|
||||
|
||||
for mention_tag in mention_tags:
|
||||
mention = {
|
||||
"id": mention_tag.get("id"),
|
||||
"entity_identifier": mention_tag.get("entity_identifier"),
|
||||
"entity_name": mention_tag.get("entity_name"),
|
||||
}
|
||||
mentions.append(mention)
|
||||
soup = BeautifulSoup(description_html, "html.parser")
|
||||
results = {}
|
||||
|
||||
for component, config in component_map.items():
|
||||
attributes = config.get("attributes", ["id"])
|
||||
component_tags = soup.find_all(component)
|
||||
|
||||
entities = []
|
||||
for tag in component_tags:
|
||||
entity = {attr: tag.get(attr) for attr in attributes}
|
||||
entities.append(entity)
|
||||
|
||||
results[component] = entities
|
||||
|
||||
return results
|
||||
|
||||
return mentions
|
||||
except Exception:
|
||||
return []
|
||||
return {component: [] for component in component_map.keys()}
|
||||
|
||||
|
||||
def get_entity_details(component: str, mention: dict):
|
||||
"""
|
||||
Normalizes mention attributes into entity_name, entity_type, entity_identifier.
|
||||
"""
|
||||
config = component_map.get(component)
|
||||
if not config:
|
||||
return {"entity_name": None, "entity_type": None, "entity_identifier": None}
|
||||
return config["extract"](mention)
|
||||
|
||||
|
||||
@shared_task
|
||||
def page_transaction(new_value, old_value, page_id):
|
||||
def page_transaction(new_description_html, old_description_html, page_id):
|
||||
"""
|
||||
Tracks changes in page content (mentions, embeds, etc.)
|
||||
and logs them in PageLog for audit and reference.
|
||||
"""
|
||||
try:
|
||||
page = Page.objects.get(pk=page_id)
|
||||
new_page_mention = PageLog.objects.filter(page_id=page_id).exists()
|
||||
|
||||
old_value = json.loads(old_value) if old_value else {}
|
||||
has_existing_logs = PageLog.objects.filter(page_id=page_id).exists()
|
||||
|
||||
|
||||
# Extract all components in a single pass (optimized)
|
||||
old_components = extract_all_components(old_description_html)
|
||||
new_components = extract_all_components(new_description_html)
|
||||
|
||||
new_transactions = []
|
||||
deleted_transaction_ids = set()
|
||||
|
||||
# TODO - Add "issue-embed-component", "img", "todo" components
|
||||
components = ["mention-component"]
|
||||
for component in components:
|
||||
old_mentions = extract_components(old_value, component)
|
||||
new_mentions = extract_components(new_value, component)
|
||||
for component in component_map.keys():
|
||||
old_entities = old_components[component]
|
||||
new_entities = new_components[component]
|
||||
|
||||
new_mentions_ids = {mention["id"] for mention in new_mentions}
|
||||
old_mention_ids = {mention["id"] for mention in old_mentions}
|
||||
deleted_transaction_ids.update(old_mention_ids - new_mentions_ids)
|
||||
old_ids = {m.get("id") for m in old_entities if m.get("id")}
|
||||
new_ids = {m.get("id") for m in new_entities if m.get("id")}
|
||||
deleted_transaction_ids.update(old_ids - new_ids)
|
||||
|
||||
new_transactions.extend(
|
||||
PageLog(
|
||||
transaction=mention["id"],
|
||||
page_id=page_id,
|
||||
entity_identifier=mention["entity_identifier"],
|
||||
entity_name=mention["entity_name"],
|
||||
workspace_id=page.workspace_id,
|
||||
created_at=timezone.now(),
|
||||
updated_at=timezone.now(),
|
||||
for mention in new_entities:
|
||||
mention_id = mention.get("id")
|
||||
if not mention_id or (mention_id in old_ids and has_existing_logs):
|
||||
continue
|
||||
|
||||
details = get_entity_details(component, mention)
|
||||
current_time = timezone.now()
|
||||
|
||||
new_transactions.append(
|
||||
PageLog(
|
||||
transaction=mention_id,
|
||||
page_id=page_id,
|
||||
entity_identifier=details["entity_identifier"],
|
||||
entity_name=details["entity_name"],
|
||||
entity_type=details["entity_type"],
|
||||
workspace_id=page.workspace_id,
|
||||
created_at=current_time,
|
||||
updated_at=current_time,
|
||||
)
|
||||
)
|
||||
for mention in new_mentions
|
||||
if mention["id"] not in old_mention_ids or not new_page_mention
|
||||
|
||||
|
||||
# Bulk insert and cleanup
|
||||
if new_transactions:
|
||||
PageLog.objects.bulk_create(
|
||||
new_transactions, batch_size=50, ignore_conflicts=True
|
||||
)
|
||||
|
||||
# Create new PageLog objects for new transactions
|
||||
PageLog.objects.bulk_create(new_transactions, batch_size=10, ignore_conflicts=True)
|
||||
if deleted_transaction_ids:
|
||||
PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete()
|
||||
|
||||
# Delete the removed transactions
|
||||
PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete()
|
||||
except Page.DoesNotExist:
|
||||
return
|
||||
except Exception as e:
|
||||
|
||||
@@ -48,6 +48,8 @@ from plane.db.models import (
|
||||
)
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.settings.mongo import MongoConnection
|
||||
|
||||
|
||||
SERIALIZER_MAPPER = {
|
||||
"project": ProjectSerializer,
|
||||
@@ -84,6 +86,58 @@ def get_issue_prefetches():
|
||||
]
|
||||
|
||||
|
||||
|
||||
def save_webhook_log(
|
||||
webhook: Webhook,
|
||||
request_method: str,
|
||||
request_headers: str,
|
||||
request_body: str,
|
||||
response_status: str,
|
||||
response_headers: str,
|
||||
response_body: str,
|
||||
retry_count: int,
|
||||
event_type: str,
|
||||
) -> None:
|
||||
|
||||
# webhook_logs
|
||||
mongo_collection = MongoConnection.get_collection("webhook_logs")
|
||||
|
||||
log_data = {
|
||||
"workspace_id": str(webhook.workspace_id),
|
||||
"webhook": str(webhook.id),
|
||||
"event_type": str(event_type),
|
||||
"request_method": str(request_method),
|
||||
"request_headers": str(request_headers),
|
||||
"request_body": str(request_body),
|
||||
"response_status": str(response_status),
|
||||
"response_headers": str(response_headers),
|
||||
"response_body": str(response_body),
|
||||
"retry_count": retry_count,
|
||||
}
|
||||
|
||||
mongo_save_success = False
|
||||
if mongo_collection is not None:
|
||||
try:
|
||||
# insert the log data into the mongo collection
|
||||
mongo_collection.insert_one(log_data)
|
||||
logger.info("Webhook log saved successfully to mongo")
|
||||
mongo_save_success = True
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
logger.error(f"Failed to save webhook log: {e}")
|
||||
mongo_save_success = False
|
||||
|
||||
# if the mongo save is not successful, save the log data into the database
|
||||
if not mongo_save_success:
|
||||
try:
|
||||
# insert the log data into the database
|
||||
WebhookLog.objects.create(**log_data)
|
||||
logger.info("Webhook log saved successfully to database")
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
logger.error(f"Failed to save webhook log: {e}")
|
||||
|
||||
|
||||
def get_model_data(event: str, event_id: Union[str, List[str]], many: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
Retrieve and serialize model data based on the event type.
|
||||
@@ -273,32 +327,30 @@ def webhook_send_task(
|
||||
response = requests.post(webhook.url, headers=headers, json=payload, timeout=30)
|
||||
|
||||
# Log the webhook request
|
||||
WebhookLog.objects.create(
|
||||
workspace_id=str(webhook.workspace_id),
|
||||
webhook=str(webhook.id),
|
||||
event_type=str(event),
|
||||
request_method=str(action),
|
||||
request_headers=str(headers),
|
||||
request_body=str(payload),
|
||||
response_status=str(response.status_code),
|
||||
response_headers=str(response.headers),
|
||||
response_body=str(response.text),
|
||||
retry_count=str(self.request.retries),
|
||||
save_webhook_log(
|
||||
webhook=webhook,
|
||||
request_method=action,
|
||||
request_headers=headers,
|
||||
request_body=payload,
|
||||
response_status=response.status_code,
|
||||
response_headers=response.headers,
|
||||
response_body=response.text,
|
||||
retry_count=self.request.retries,
|
||||
event_type=event,
|
||||
)
|
||||
logger.info(f"Webhook {webhook.id} sent successfully")
|
||||
except requests.RequestException as e:
|
||||
# Log the failed webhook request
|
||||
WebhookLog.objects.create(
|
||||
workspace_id=str(webhook.workspace_id),
|
||||
webhook=str(webhook.id),
|
||||
event_type=str(event),
|
||||
request_method=str(action),
|
||||
request_headers=str(headers),
|
||||
request_body=str(payload),
|
||||
save_webhook_log(
|
||||
webhook=webhook,
|
||||
request_method=action,
|
||||
request_headers=headers,
|
||||
request_body=payload,
|
||||
response_status=500,
|
||||
response_headers="",
|
||||
response_body=str(e),
|
||||
retry_count=str(self.request.retries),
|
||||
retry_count=self.request.retries,
|
||||
event_type=event,
|
||||
)
|
||||
logger.error(f"Webhook {webhook.id} failed with error: {e}")
|
||||
# Retry logic
|
||||
|
||||
@@ -12,7 +12,6 @@ from rest_framework.request import Request
|
||||
from plane.utils.ip_address import get_client_ip
|
||||
from plane.db.models import APIActivityLog
|
||||
|
||||
|
||||
api_logger = logging.getLogger("plane.api.request")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
from django.core.exceptions import RequestDataTooBig
|
||||
from django.http import JsonResponse
|
||||
|
||||
|
||||
class RequestBodySizeLimitMiddleware:
|
||||
"""
|
||||
Middleware to catch RequestDataTooBig exceptions and return
|
||||
413 Request Entity Too Large instead of 400 Bad Request.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
try:
|
||||
_ = request.body
|
||||
except RequestDataTooBig:
|
||||
return JsonResponse(
|
||||
{
|
||||
"error": "REQUEST_BODY_TOO_LARGE",
|
||||
"detail": "The size of the request body exceeds the maximum allowed size.",
|
||||
},
|
||||
status=413,
|
||||
)
|
||||
|
||||
# If body size is OK, continue with the request
|
||||
return self.get_response(request)
|
||||
@@ -62,6 +62,7 @@ MIDDLEWARE = [
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"crum.CurrentRequestUserMiddleware",
|
||||
"django.middleware.gzip.GZipMiddleware",
|
||||
"plane.middleware.request_body_size.RequestBodySizeLimitMiddleware",
|
||||
"plane.middleware.logger.APITokenLogMiddleware",
|
||||
"plane.middleware.logger.RequestLoggerMiddleware",
|
||||
]
|
||||
@@ -299,14 +300,14 @@ DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
|
||||
SESSION_COOKIE_SECURE = secure_origins
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_ENGINE = "plane.db.models.session"
|
||||
SESSION_COOKIE_AGE = os.environ.get("SESSION_COOKIE_AGE", 604800)
|
||||
SESSION_COOKIE_AGE = int(os.environ.get("SESSION_COOKIE_AGE", 604800))
|
||||
SESSION_COOKIE_NAME = os.environ.get("SESSION_COOKIE_NAME", "session-id")
|
||||
SESSION_COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", None)
|
||||
SESSION_SAVE_EVERY_REQUEST = os.environ.get("SESSION_SAVE_EVERY_REQUEST", "0") == "1"
|
||||
|
||||
# Admin Cookie
|
||||
ADMIN_SESSION_COOKIE_NAME = "admin-session-id"
|
||||
ADMIN_SESSION_COOKIE_AGE = os.environ.get("ADMIN_SESSION_COOKIE_AGE", 3600)
|
||||
ADMIN_SESSION_COOKIE_AGE = int(os.environ.get("ADMIN_SESSION_COOKIE_AGE", 3600))
|
||||
|
||||
# CSRF cookies
|
||||
CSRF_COOKIE_SECURE = secure_origins
|
||||
|
||||
@@ -4,7 +4,9 @@ import nh3
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from bs4 import BeautifulSoup
|
||||
from collections import defaultdict
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("plane.api")
|
||||
|
||||
# Maximum allowed size for binary data (10MB)
|
||||
MAX_SIZE = 10 * 1024 * 1024
|
||||
@@ -54,7 +56,9 @@ def validate_binary_data(data):
|
||||
# Check for suspicious text patterns (HTML/JS)
|
||||
try:
|
||||
decoded_text = binary_data.decode("utf-8", errors="ignore")[:200]
|
||||
if any(pattern in decoded_text.lower() for pattern in SUSPICIOUS_BINARY_PATTERNS):
|
||||
if any(
|
||||
pattern in decoded_text.lower() for pattern in SUSPICIOUS_BINARY_PATTERNS
|
||||
):
|
||||
return False, "Binary data contains suspicious content patterns"
|
||||
except Exception:
|
||||
pass # Binary data might not be decodable as text, which is fine
|
||||
@@ -232,8 +236,9 @@ def validate_html_content(html_content: str):
|
||||
summary = json.dumps(diff)
|
||||
except Exception:
|
||||
summary = str(diff)
|
||||
logger.warning(f"HTML sanitization removals: {summary}")
|
||||
log_exception(
|
||||
f"HTML sanitization removals: {summary}",
|
||||
ValueError(f"HTML sanitization removals: {summary}"),
|
||||
warning=True,
|
||||
)
|
||||
return True, None, clean_html
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.db.models import (
|
||||
Case,
|
||||
Count,
|
||||
F,
|
||||
Q,
|
||||
Sum,
|
||||
FloatField,
|
||||
Value,
|
||||
When,
|
||||
)
|
||||
from django.db import models
|
||||
from django.db.models.functions import Cast, Concat
|
||||
from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
Cycle,
|
||||
CycleIssue,
|
||||
Issue,
|
||||
Project,
|
||||
)
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
def transfer_cycle_issues(
|
||||
slug,
|
||||
project_id,
|
||||
cycle_id,
|
||||
new_cycle_id,
|
||||
request,
|
||||
user_id,
|
||||
):
|
||||
"""
|
||||
Transfer incomplete issues from one cycle to another and create progress snapshot.
|
||||
|
||||
Args:
|
||||
slug: Workspace slug
|
||||
project_id: Project ID
|
||||
cycle_id: Source cycle ID
|
||||
new_cycle_id: Destination cycle ID
|
||||
request: HTTP request object
|
||||
user_id: User ID performing the transfer
|
||||
|
||||
Returns:
|
||||
dict: Response data with success or error message
|
||||
"""
|
||||
# Get the new cycle
|
||||
new_cycle = Cycle.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=new_cycle_id
|
||||
).first()
|
||||
|
||||
# Check if new cycle is already completed
|
||||
if new_cycle.end_date is not None and new_cycle.end_date < timezone.now():
|
||||
return {
|
||||
"success": False,
|
||||
"error": "The cycle where the issues are transferred is already completed",
|
||||
}
|
||||
|
||||
# Get the old cycle with issue counts
|
||||
old_cycle = (
|
||||
Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle",
|
||||
filter=Q(
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="completed",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="cancelled",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="started",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="unstarted",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="backlog",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
old_cycle = old_cycle.first()
|
||||
|
||||
if old_cycle is None:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Source cycle not found",
|
||||
}
|
||||
|
||||
# Check if project uses estimates
|
||||
estimate_type = Project.objects.filter(
|
||||
workspace__slug=slug,
|
||||
pk=project_id,
|
||||
estimate__isnull=False,
|
||||
estimate__type="points",
|
||||
).exists()
|
||||
|
||||
# Initialize estimate distribution variables
|
||||
assignee_estimate_distribution = []
|
||||
label_estimate_distribution = []
|
||||
estimate_completion_chart = {}
|
||||
|
||||
if estimate_type:
|
||||
assignee_estimate_data = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(
|
||||
avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset",
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(
|
||||
assignees__avatar_asset__isnull=True,
|
||||
then="assignees__avatar",
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar_url")
|
||||
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("display_name")
|
||||
)
|
||||
# Assignee estimate distribution serialization
|
||||
assignee_estimate_distribution = [
|
||||
{
|
||||
"display_name": item["display_name"],
|
||||
"assignee_id": (
|
||||
str(item["assignee_id"]) if item["assignee_id"] else None
|
||||
),
|
||||
"avatar_url": item.get("avatar_url"),
|
||||
"total_estimates": item["total_estimates"],
|
||||
"completed_estimates": item["completed_estimates"],
|
||||
"pending_estimates": item["pending_estimates"],
|
||||
}
|
||||
for item in assignee_estimate_data
|
||||
]
|
||||
|
||||
label_distribution_data = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
|
||||
estimate_completion_chart = burndown_plot(
|
||||
queryset=old_cycle,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type="points",
|
||||
cycle_id=cycle_id,
|
||||
)
|
||||
# Label estimate distribution serialization
|
||||
label_estimate_distribution = [
|
||||
{
|
||||
"label_name": item["label_name"],
|
||||
"color": item["color"],
|
||||
"label_id": (str(item["label_id"]) if item["label_id"] else None),
|
||||
"total_estimates": item["total_estimates"],
|
||||
"completed_estimates": item["completed_estimates"],
|
||||
"pending_estimates": item["pending_estimates"],
|
||||
}
|
||||
for item in label_distribution_data
|
||||
]
|
||||
|
||||
# Get the assignee distribution
|
||||
assignee_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(
|
||||
avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset",
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(assignees__avatar_asset__isnull=True, then="assignees__avatar"),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar_url")
|
||||
.annotate(
|
||||
total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("display_name")
|
||||
)
|
||||
# Assignee distribution serialized
|
||||
assignee_distribution_data = [
|
||||
{
|
||||
"display_name": item["display_name"],
|
||||
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
|
||||
"avatar_url": item.get("avatar_url"),
|
||||
"total_issues": item["total_issues"],
|
||||
"completed_issues": item["completed_issues"],
|
||||
"pending_issues": item["pending_issues"],
|
||||
}
|
||||
for item in assignee_distribution
|
||||
]
|
||||
|
||||
# Get the label distribution
|
||||
label_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
|
||||
# Label distribution serialization
|
||||
label_distribution_data = [
|
||||
{
|
||||
"label_name": item["label_name"],
|
||||
"color": item["color"],
|
||||
"label_id": (str(item["label_id"]) if item["label_id"] else None),
|
||||
"total_issues": item["total_issues"],
|
||||
"completed_issues": item["completed_issues"],
|
||||
"pending_issues": item["pending_issues"],
|
||||
}
|
||||
for item in label_distribution
|
||||
]
|
||||
|
||||
# Generate completion chart
|
||||
completion_chart = burndown_plot(
|
||||
queryset=old_cycle,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type="issues",
|
||||
cycle_id=cycle_id,
|
||||
)
|
||||
|
||||
# Get the current cycle and save progress snapshot
|
||||
current_cycle = Cycle.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
||||
).first()
|
||||
|
||||
current_cycle.progress_snapshot = {
|
||||
"total_issues": old_cycle.total_issues,
|
||||
"completed_issues": old_cycle.completed_issues,
|
||||
"cancelled_issues": old_cycle.cancelled_issues,
|
||||
"started_issues": old_cycle.started_issues,
|
||||
"unstarted_issues": old_cycle.unstarted_issues,
|
||||
"backlog_issues": old_cycle.backlog_issues,
|
||||
"distribution": {
|
||||
"labels": label_distribution_data,
|
||||
"assignees": assignee_distribution_data,
|
||||
"completion_chart": completion_chart,
|
||||
},
|
||||
"estimate_distribution": (
|
||||
{}
|
||||
if not estimate_type
|
||||
else {
|
||||
"labels": label_estimate_distribution,
|
||||
"assignees": assignee_estimate_distribution,
|
||||
"completion_chart": estimate_completion_chart,
|
||||
}
|
||||
),
|
||||
}
|
||||
current_cycle.save(update_fields=["progress_snapshot"])
|
||||
|
||||
# Get issues to transfer (only incomplete issues)
|
||||
cycle_issues = CycleIssue.objects.filter(
|
||||
cycle_id=cycle_id,
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
issue__archived_at__isnull=True,
|
||||
issue__is_draft=False,
|
||||
issue__state__group__in=["backlog", "unstarted", "started"],
|
||||
)
|
||||
|
||||
updated_cycles = []
|
||||
update_cycle_issue_activity = []
|
||||
for cycle_issue in cycle_issues:
|
||||
cycle_issue.cycle_id = new_cycle_id
|
||||
updated_cycles.append(cycle_issue)
|
||||
update_cycle_issue_activity.append(
|
||||
{
|
||||
"old_cycle_id": str(cycle_id),
|
||||
"new_cycle_id": str(new_cycle_id),
|
||||
"issue_id": str(cycle_issue.issue_id),
|
||||
}
|
||||
)
|
||||
|
||||
# Bulk update cycle issues
|
||||
cycle_issues = CycleIssue.objects.bulk_update(
|
||||
updated_cycles, ["cycle_id"], batch_size=100
|
||||
)
|
||||
|
||||
# Capture Issue Activity
|
||||
issue_activity.delay(
|
||||
type="cycle.activity.created",
|
||||
requested_data=json.dumps({"cycles_list": []}),
|
||||
actor_id=str(user_id),
|
||||
issue_id=None,
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps(
|
||||
{
|
||||
"updated_cycle_issues": update_cycle_issue_activity,
|
||||
"created_cycle_issues": [],
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
|
||||
return {"success": True}
|
||||
@@ -169,12 +169,16 @@ class IssueExportSchema(ExportSchema):
|
||||
def prepare_cycle_start_date(self, i):
|
||||
cycles_dict = self.context.get("cycles_dict") or {}
|
||||
last_cycle = cycles_dict.get(i.id)
|
||||
return last_cycle.cycle.start_date if last_cycle else None
|
||||
if last_cycle and last_cycle.cycle.start_date:
|
||||
return self._format_date(last_cycle.cycle.start_date)
|
||||
return ""
|
||||
|
||||
def prepare_cycle_end_date(self, i):
|
||||
cycles_dict = self.context.get("cycles_dict") or {}
|
||||
last_cycle = cycles_dict.get(i.id)
|
||||
return last_cycle.cycle.end_date if last_cycle else None
|
||||
if last_cycle and last_cycle.cycle.end_date:
|
||||
return self._format_date(last_cycle.cycle.end_date)
|
||||
return ""
|
||||
|
||||
def prepare_parent(self, i):
|
||||
if not i.parent:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "live",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "A realtime collaborative server powers Plane's rich text editor",
|
||||
"main": "./dist/start.js",
|
||||
@@ -20,11 +20,11 @@
|
||||
"author": "Plane Software Inc.",
|
||||
"dependencies": {
|
||||
"@dotenvx/dotenvx": "^1.49.0",
|
||||
"@hocuspocus/extension-database": "3.2.5",
|
||||
"@hocuspocus/extension-logger": "3.2.5",
|
||||
"@hocuspocus/extension-redis": "3.2.5",
|
||||
"@hocuspocus/server": "3.2.5",
|
||||
"@hocuspocus/transformer": "3.2.5",
|
||||
"@hocuspocus/extension-database": "2.15.2",
|
||||
"@hocuspocus/extension-logger": "2.15.2",
|
||||
"@hocuspocus/extension-redis": "2.15.2",
|
||||
"@hocuspocus/server": "2.15.2",
|
||||
"@hocuspocus/transformer": "2.15.2",
|
||||
"@plane/decorators": "workspace:*",
|
||||
"@plane/editor": "workspace:*",
|
||||
"@plane/logger": "workspace:*",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as dotenv from "@dotenvx/dotenvx";
|
||||
import { z } from "zod";
|
||||
import { logger } from "@plane/logger";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -28,7 +27,7 @@ const envSchema = z.object({
|
||||
const validateEnv = () => {
|
||||
const result = envSchema.safeParse(process.env);
|
||||
if (!result.success) {
|
||||
logger.error("❌ Invalid environment variables:", JSON.stringify(result.error.format(), null, 4));
|
||||
console.error("❌ Invalid environment variables:", JSON.stringify(result.error.format(), null, 4));
|
||||
process.exit(1);
|
||||
}
|
||||
return result.data;
|
||||
|
||||
@@ -6,22 +6,17 @@ import {
|
||||
} from "@plane/editor";
|
||||
// logger
|
||||
import { logger } from "@plane/logger";
|
||||
// lib
|
||||
import { AppError } from "@/lib/errors";
|
||||
// services
|
||||
import { getPageService } from "@/services/page/handler";
|
||||
// type
|
||||
import type { FetchPayloadWithContext, StorePayloadWithContext } from "@/types";
|
||||
import { ForceCloseReason, CloseCode } from "@/types/admin-commands";
|
||||
import { broadcastError } from "@/utils/broadcast-error";
|
||||
// force close utility
|
||||
import { forceCloseDocumentAcrossServers } from "./force-close-handler";
|
||||
|
||||
const normalizeToError = (error: unknown, fallbackMessage: string) => {
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
}
|
||||
|
||||
const message = typeof error === "string" && error.trim().length > 0 ? error : fallbackMessage;
|
||||
|
||||
return new Error(message);
|
||||
};
|
||||
|
||||
const fetchDocument = async ({ context, documentName: pageId }: FetchPayloadWithContext) => {
|
||||
const fetchDocument = async ({ context, documentName: pageId, instance }: FetchPayloadWithContext) => {
|
||||
try {
|
||||
const service = getPageService(context.documentType, context);
|
||||
// fetch details
|
||||
@@ -38,12 +33,22 @@ const fetchDocument = async ({ context, documentName: pageId }: FetchPayloadWith
|
||||
// return binary data
|
||||
return binaryData;
|
||||
} catch (error) {
|
||||
logger.error("DATABASE_EXTENSION: Error in fetching document", error);
|
||||
throw normalizeToError(error, `Failed to fetch document: ${pageId}`);
|
||||
const appError = new AppError(error, { context: { pageId } });
|
||||
logger.error("Error in fetching document", appError);
|
||||
|
||||
// Broadcast error to frontend for user document types
|
||||
await broadcastError(instance, pageId, "Unable to load the page. Please try refreshing.", "fetch", context);
|
||||
|
||||
throw appError;
|
||||
}
|
||||
};
|
||||
|
||||
const storeDocument = async ({ context, state: pageBinaryData, documentName: pageId }: StorePayloadWithContext) => {
|
||||
const storeDocument = async ({
|
||||
context,
|
||||
state: pageBinaryData,
|
||||
documentName: pageId,
|
||||
instance,
|
||||
}: StorePayloadWithContext) => {
|
||||
try {
|
||||
const service = getPageService(context.documentType, context);
|
||||
// convert binary data to all formats
|
||||
@@ -57,8 +62,46 @@ const storeDocument = async ({ context, state: pageBinaryData, documentName: pag
|
||||
};
|
||||
await service.updateDescriptionBinary(pageId, payload);
|
||||
} catch (error) {
|
||||
logger.error("DATABASE_EXTENSION: Error in updating document:", error);
|
||||
throw normalizeToError(error, `Failed to update document: ${pageId}`);
|
||||
const appError = new AppError(error, { context: { pageId } });
|
||||
logger.error("Error in updating document:", appError);
|
||||
|
||||
// Check error types
|
||||
const isContentTooLarge = appError.statusCode === 413;
|
||||
|
||||
// Determine if we should disconnect and unload
|
||||
const shouldDisconnect = isContentTooLarge;
|
||||
|
||||
// Determine error message and code
|
||||
let errorMessage: string;
|
||||
let errorCode: "content_too_large" | "page_locked" | "page_archived" | undefined;
|
||||
|
||||
if (isContentTooLarge) {
|
||||
errorMessage = "Document is too large to save. Please reduce the content size.";
|
||||
errorCode = "content_too_large";
|
||||
} else {
|
||||
errorMessage = "Unable to save the page. Please try again.";
|
||||
}
|
||||
|
||||
// Broadcast error to frontend for user document types
|
||||
await broadcastError(instance, pageId, errorMessage, "store", context, errorCode, shouldDisconnect);
|
||||
|
||||
// If we should disconnect, close connections and unload document
|
||||
if (shouldDisconnect) {
|
||||
// Map error code to ForceCloseReason with proper types
|
||||
const reason =
|
||||
errorCode === "content_too_large" ? ForceCloseReason.DOCUMENT_TOO_LARGE : ForceCloseReason.CRITICAL_ERROR;
|
||||
|
||||
const closeCode = errorCode === "content_too_large" ? CloseCode.DOCUMENT_TOO_LARGE : CloseCode.FORCE_CLOSE;
|
||||
|
||||
// force close connections and unload document
|
||||
await forceCloseDocumentAcrossServers(instance, pageId, reason, closeCode);
|
||||
|
||||
// Don't throw after force close - document is already unloaded
|
||||
// Throwing would cause hocuspocus's finally block to access the null document
|
||||
return;
|
||||
}
|
||||
|
||||
throw appError;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { Connection, Extension, Hocuspocus, onConfigurePayload } from "@hocuspocus/server";
|
||||
import { logger } from "@plane/logger";
|
||||
import { Redis } from "@/extensions/redis";
|
||||
import {
|
||||
AdminCommand,
|
||||
CloseCode,
|
||||
ForceCloseReason,
|
||||
getForceCloseMessage,
|
||||
isForceCloseCommand,
|
||||
type ClientForceCloseMessage,
|
||||
type ForceCloseCommandData,
|
||||
} from "@/types/admin-commands";
|
||||
|
||||
/**
|
||||
* Extension to handle force close commands from other servers via Redis admin channel
|
||||
*/
|
||||
export class ForceCloseHandler implements Extension {
|
||||
name = "ForceCloseHandler";
|
||||
priority = 999;
|
||||
|
||||
async onConfigure({ instance }: onConfigurePayload) {
|
||||
const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis) as Redis | undefined;
|
||||
|
||||
if (!redisExt) {
|
||||
logger.warn("[FORCE_CLOSE_HANDLER] Redis extension not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Register handler for force_close admin command
|
||||
redisExt.onAdminCommand<ForceCloseCommandData>(AdminCommand.FORCE_CLOSE, async (data) => {
|
||||
// Type guard for safety
|
||||
if (!isForceCloseCommand(data)) {
|
||||
logger.error("[FORCE_CLOSE_HANDLER] Received invalid force close command");
|
||||
return;
|
||||
}
|
||||
|
||||
const { docId, reason, code } = data;
|
||||
|
||||
const document = instance.documents.get(docId);
|
||||
if (!document) {
|
||||
// Not our document, ignore
|
||||
return;
|
||||
}
|
||||
|
||||
const connectionCount = document.getConnectionsCount();
|
||||
logger.info(`[FORCE_CLOSE_HANDLER] Sending force close message to ${connectionCount} clients...`);
|
||||
|
||||
// Step 1: Send force close message to ALL clients first
|
||||
const forceCloseMessage: ClientForceCloseMessage = {
|
||||
type: "force_close",
|
||||
reason,
|
||||
code,
|
||||
message: getForceCloseMessage(reason),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
let messageSent = 0;
|
||||
document.connections.forEach(({ connection }: { connection: Connection }) => {
|
||||
try {
|
||||
connection.sendStateless(JSON.stringify(forceCloseMessage));
|
||||
messageSent++;
|
||||
} catch (error) {
|
||||
logger.error("[FORCE_CLOSE_HANDLER] Failed to send message:", error);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[FORCE_CLOSE_HANDLER] Sent force close message to ${messageSent}/${connectionCount} clients`);
|
||||
|
||||
// Wait a moment for messages to be delivered
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Step 2: Close connections
|
||||
logger.info(`[FORCE_CLOSE_HANDLER] Closing ${connectionCount} connections...`);
|
||||
|
||||
let closed = 0;
|
||||
document.connections.forEach(({ connection }: { connection: Connection }) => {
|
||||
try {
|
||||
connection.close({ code, reason });
|
||||
closed++;
|
||||
} catch (error) {
|
||||
logger.error("[FORCE_CLOSE_HANDLER] Failed to close connection:", error);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[FORCE_CLOSE_HANDLER] Closed ${closed}/${connectionCount} connections for ${docId}`);
|
||||
});
|
||||
|
||||
logger.info("[FORCE_CLOSE_HANDLER] Registered with Redis extension");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force close all connections to a document across all servers and unload it from memory.
|
||||
* Used for critical errors or admin operations.
|
||||
*
|
||||
* @param instance - The Hocuspocus server instance
|
||||
* @param pageId - The document ID to force close
|
||||
* @param reason - The reason for force closing
|
||||
* @param code - Optional WebSocket close code (defaults to FORCE_CLOSE)
|
||||
* @returns Promise that resolves when document is closed and unloaded
|
||||
* @throws Error if document not found in memory
|
||||
*/
|
||||
export const forceCloseDocumentAcrossServers = async (
|
||||
instance: Hocuspocus,
|
||||
pageId: string,
|
||||
reason: ForceCloseReason,
|
||||
code: CloseCode = CloseCode.FORCE_CLOSE
|
||||
): Promise<void> => {
|
||||
// STEP 1: VERIFY DOCUMENT EXISTS
|
||||
const document = instance.documents.get(pageId);
|
||||
|
||||
if (!document) {
|
||||
logger.info(`[FORCE_CLOSE] Document ${pageId} already unloaded - no action needed`);
|
||||
return; // Document already cleaned up, nothing to do
|
||||
}
|
||||
|
||||
const connectionsBefore = document.getConnectionsCount();
|
||||
logger.info(`[FORCE_CLOSE] Sending force close message to ${connectionsBefore} local clients...`);
|
||||
|
||||
const forceCloseMessage: ClientForceCloseMessage = {
|
||||
type: "force_close",
|
||||
reason,
|
||||
code,
|
||||
message: getForceCloseMessage(reason),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
let messageSentCount = 0;
|
||||
document.connections.forEach(({ connection }: { connection: Connection }) => {
|
||||
try {
|
||||
connection.sendStateless(JSON.stringify(forceCloseMessage));
|
||||
messageSentCount++;
|
||||
} catch (error) {
|
||||
logger.error("[FORCE_CLOSE] Failed to send message to client:", error);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[FORCE_CLOSE] Sent force close message to ${messageSentCount}/${connectionsBefore} clients`);
|
||||
|
||||
// Wait a moment for messages to be delivered
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// STEP 3: CLOSE LOCAL CONNECTIONS
|
||||
logger.info(`[FORCE_CLOSE] Closing ${connectionsBefore} local connections...`);
|
||||
|
||||
let closedCount = 0;
|
||||
document.connections.forEach(({ connection }: { connection: Connection }) => {
|
||||
try {
|
||||
connection.close({ code, reason });
|
||||
closedCount++;
|
||||
} catch (error) {
|
||||
logger.error("[FORCE_CLOSE] Failed to close local connection:", error);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[FORCE_CLOSE] Closed ${closedCount}/${connectionsBefore} local connections`);
|
||||
|
||||
// STEP 4: BROADCAST TO OTHER SERVERS
|
||||
const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis) as Redis | undefined;
|
||||
|
||||
if (redisExt) {
|
||||
const commandData: ForceCloseCommandData = {
|
||||
command: AdminCommand.FORCE_CLOSE,
|
||||
docId: pageId,
|
||||
reason,
|
||||
code,
|
||||
originServer: instance.configuration.name || "unknown",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const receivers = await redisExt.publishAdminCommand(commandData);
|
||||
logger.info(`[FORCE_CLOSE] Notified ${receivers} other server(s)`);
|
||||
} else {
|
||||
logger.warn("[FORCE_CLOSE] Redis extension not found, cannot notify other servers");
|
||||
}
|
||||
|
||||
// STEP 5: WAIT FOR OTHER SERVERS
|
||||
const waitTime = 800;
|
||||
logger.info(`[FORCE_CLOSE] Waiting ${waitTime}ms for other servers to close connections...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, waitTime));
|
||||
|
||||
// STEP 6: UNLOAD DOCUMENT after closing all the connections
|
||||
logger.info(`[FORCE_CLOSE] Unloading document from memory...`);
|
||||
|
||||
try {
|
||||
await instance.unloadDocument(document);
|
||||
logger.info(`[FORCE_CLOSE] Document unloaded successfully ✅`);
|
||||
} catch (unloadError: unknown) {
|
||||
logger.error("[FORCE_CLOSE] UNLOAD FAILED:", unloadError);
|
||||
logger.error(` Error: ${unloadError instanceof Error ? unloadError.message : "unknown"}`);
|
||||
}
|
||||
|
||||
// STEP 7: VERIFY UNLOAD
|
||||
const documentAfterUnload = instance.documents.get(pageId);
|
||||
|
||||
if (documentAfterUnload) {
|
||||
logger.error(
|
||||
`❌ [FORCE_CLOSE] Document still in memory!, Document ID: ${pageId}, Connections: ${documentAfterUnload.getConnectionsCount()}`
|
||||
);
|
||||
} else {
|
||||
logger.info(`✅ [FORCE_CLOSE] COMPLETE, Document: ${pageId}, Status: Successfully closed and unloaded`);
|
||||
}
|
||||
};
|
||||
@@ -1,30 +1,134 @@
|
||||
import { Redis as HocuspocusRedis } from "@hocuspocus/extension-redis";
|
||||
import { OutgoingMessage } from "@hocuspocus/server";
|
||||
// redis
|
||||
import { OutgoingMessage, type onConfigurePayload } from "@hocuspocus/server";
|
||||
import { logger } from "@plane/logger";
|
||||
import { AppError } from "@/lib/errors";
|
||||
import { redisManager } from "@/redis";
|
||||
import { AdminCommand } from "@/types/admin-commands";
|
||||
import type { AdminCommandData, AdminCommandHandler } from "@/types/admin-commands";
|
||||
|
||||
const getRedisClient = () => {
|
||||
const redisClient = redisManager.getClient();
|
||||
if (!redisClient) {
|
||||
throw new Error("Redis client not initialized");
|
||||
throw new AppError("Redis client not initialized");
|
||||
}
|
||||
return redisClient;
|
||||
};
|
||||
|
||||
export class Redis extends HocuspocusRedis {
|
||||
private adminHandlers = new Map<AdminCommand, AdminCommandHandler>();
|
||||
private readonly ADMIN_CHANNEL = "hocuspocus:admin";
|
||||
|
||||
constructor() {
|
||||
super({ redis: getRedisClient() });
|
||||
}
|
||||
|
||||
public broadcastToDocument(documentName: string, payload: any): Promise<number> {
|
||||
async onConfigure(payload: onConfigurePayload) {
|
||||
await super.onConfigure(payload);
|
||||
|
||||
// Subscribe to admin channel
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.sub.subscribe(this.ADMIN_CHANNEL, (error: Error) => {
|
||||
if (error) {
|
||||
logger.error(`[Redis] Failed to subscribe to admin channel:`, error);
|
||||
reject(error);
|
||||
} else {
|
||||
logger.info(`[Redis] Subscribed to admin channel: ${this.ADMIN_CHANNEL}`);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Listen for admin messages
|
||||
this.sub.on("message", this.handleAdminMessage);
|
||||
logger.info(`[Redis] Attached admin message listener`);
|
||||
}
|
||||
|
||||
private handleAdminMessage = async (channel: string, message: string) => {
|
||||
if (channel !== this.ADMIN_CHANNEL) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(message) as AdminCommandData;
|
||||
|
||||
// Validate command
|
||||
if (!data.command || !Object.values(AdminCommand).includes(data.command as AdminCommand)) {
|
||||
logger.warn(`[Redis] Invalid admin command received: ${data.command}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = this.adminHandlers.get(data.command);
|
||||
|
||||
if (handler) {
|
||||
await handler(data);
|
||||
} else {
|
||||
logger.warn(`[Redis] No handler registered for admin command: ${data.command}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("[Redis] Error handling admin message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Register handler for an admin command
|
||||
*/
|
||||
public onAdminCommand<T extends AdminCommandData = AdminCommandData>(
|
||||
command: AdminCommand,
|
||||
handler: AdminCommandHandler<T>
|
||||
) {
|
||||
this.adminHandlers.set(command, handler as AdminCommandHandler);
|
||||
logger.info(`[Redis] Registered admin command: ${command}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish admin command to global channel
|
||||
*/
|
||||
public async publishAdminCommand<T extends AdminCommandData>(data: T): Promise<number> {
|
||||
// Validate command data
|
||||
if (!data.command || !Object.values(AdminCommand).includes(data.command)) {
|
||||
throw new AppError(`Invalid admin command: ${data.command}`);
|
||||
}
|
||||
|
||||
const message = JSON.stringify(data);
|
||||
const receivers = await this.pub.publish(this.ADMIN_CHANNEL, message);
|
||||
|
||||
logger.info(`[Redis] Published "${data.command}" command, received by ${receivers} server(s)`);
|
||||
return receivers;
|
||||
}
|
||||
|
||||
async onDestroy() {
|
||||
// Unsubscribe from admin channel
|
||||
await new Promise<void>((resolve) => {
|
||||
this.sub.unsubscribe(this.ADMIN_CHANNEL, (error: Error) => {
|
||||
if (error) {
|
||||
logger.error(`[Redis] Error unsubscribing from admin channel:`, error);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Remove the message listener to prevent memory leaks
|
||||
this.sub.removeListener("message", this.handleAdminMessage);
|
||||
logger.info(`[Redis] Removed admin message listener`);
|
||||
|
||||
await super.onDestroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast a message to a document across all servers via Redis.
|
||||
* Uses empty identifier so ALL servers process the message.
|
||||
*/
|
||||
public async broadcastToDocument(documentName: string, payload: unknown): Promise<number> {
|
||||
const stringPayload = typeof payload === "string" ? payload : JSON.stringify(payload);
|
||||
|
||||
const message = new OutgoingMessage(documentName).writeBroadcastStateless(stringPayload);
|
||||
|
||||
return this.pub.publish(
|
||||
// we're accessing the private method of the hocuspocus redis extension
|
||||
this["pubKey"](documentName),
|
||||
// we're accessing the private method of the hocuspocus redis extension to encode the message
|
||||
this["encodeMessage"](message.toUint8Array())
|
||||
);
|
||||
const emptyPrefix = Buffer.concat([Buffer.from([0])]);
|
||||
const channel = this["pubKey"](documentName);
|
||||
const encodedMessage = Buffer.concat([emptyPrefix, Buffer.from(message.toUint8Array())]);
|
||||
|
||||
const result = await this.pub.publishBuffer(channel, encodedMessage);
|
||||
|
||||
logger.info(`REDIS_EXTENSION: Published to ${documentName}, ${result} subscribers`);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { logger } from "@plane/logger";
|
||||
import { env } from "@/env";
|
||||
|
||||
/**
|
||||
* Express middleware to verify secret key authentication for protected endpoints
|
||||
*
|
||||
* Checks for secret key in headers:
|
||||
* - x-admin-secret-key (preferred for admin endpoints)
|
||||
* - live-server-secret-key (for backward compatibility)
|
||||
*
|
||||
* @param req - Express request object
|
||||
* @param res - Express response object
|
||||
* @param next - Express next function
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Middleware } from "@plane/decorators";
|
||||
* import { requireSecretKey } from "@/lib/auth-middleware";
|
||||
*
|
||||
* @Get("/protected")
|
||||
* @Middleware(requireSecretKey)
|
||||
* async protectedEndpoint(req: Request, res: Response) {
|
||||
* // This will only execute if secret key is valid
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
// TODO - Move to hmac
|
||||
export const requireSecretKey = (req: Request, res: Response, next: NextFunction): void => {
|
||||
const secretKey = req.headers["live-server-secret-key"];
|
||||
|
||||
if (!secretKey || secretKey !== env.LIVE_SERVER_SECRET_KEY) {
|
||||
logger.warn(`
|
||||
⚠️ [AUTH] Unauthorized access attempt
|
||||
Endpoint: ${req.path}
|
||||
Method: ${req.method}
|
||||
IP: ${req.ip}
|
||||
User-Agent: ${req.headers["user-agent"]}
|
||||
`);
|
||||
|
||||
res.status(401).json({
|
||||
error: "Unauthorized",
|
||||
status: 401,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Secret key is valid, proceed to the route handler
|
||||
next();
|
||||
};
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { IncomingHttpHeaders } from "http";
|
||||
import type { TUserDetails } from "@plane/editor";
|
||||
import { logger } from "@plane/logger";
|
||||
import { AppError } from "@/lib/errors";
|
||||
// services
|
||||
import { UserService } from "@/services/user.service";
|
||||
// types
|
||||
@@ -35,8 +36,10 @@ export const onAuthenticate = async ({
|
||||
userId = parsedToken.id;
|
||||
cookie = parsedToken.cookie;
|
||||
} catch (error) {
|
||||
// If token parsing fails, fallback to request headers
|
||||
logger.error("AUTH: Token parsing failed, using request headers:", error);
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "onAuthenticate" },
|
||||
});
|
||||
logger.error("Token parsing failed, using request headers", appError);
|
||||
} finally {
|
||||
// If cookie is still not found, fallback to request headers
|
||||
if (!cookie) {
|
||||
@@ -45,7 +48,9 @@ export const onAuthenticate = async ({
|
||||
}
|
||||
|
||||
if (!cookie || !userId) {
|
||||
throw new Error("Credentials not provided");
|
||||
const appError = new AppError("Credentials not provided", { code: "AUTH_MISSING_CREDENTIALS" });
|
||||
logger.error("Credentials not provided", appError);
|
||||
throw appError;
|
||||
}
|
||||
|
||||
// set cookie in context, so it can be used throughout the ws connection
|
||||
@@ -67,7 +72,7 @@ export const handleAuthentication = async ({ cookie, userId }: { cookie: string;
|
||||
const userService = new UserService();
|
||||
const user = await userService.currentUser(cookie);
|
||||
if (user.id !== userId) {
|
||||
throw new Error("Authentication unsuccessful!");
|
||||
throw new AppError("Authentication unsuccessful: User ID mismatch", { code: "AUTH_USER_MISMATCH" });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -77,7 +82,10 @@ export const handleAuthentication = async ({ cookie, userId }: { cookie: string;
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("AUTH: Token parsing failed, using request headers:", error);
|
||||
throw Error("Authentication unsuccessful!");
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "handleAuthentication" },
|
||||
});
|
||||
logger.error("Authentication failed", appError);
|
||||
throw new AppError("Authentication unsuccessful", { code: appError.code });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
/**
|
||||
* Application error class that sanitizes and standardizes errors across the app.
|
||||
* Extracts only essential information from AxiosError to prevent massive log bloat
|
||||
* and sensitive data leaks (cookies, tokens, etc).
|
||||
*
|
||||
* Usage:
|
||||
* new AppError("Simple error message")
|
||||
* new AppError("Custom error", { code: "MY_CODE", statusCode: 400 })
|
||||
* new AppError(axiosError) // Auto-extracts essential info
|
||||
* new AppError(anyError) // Works with any error type
|
||||
*/
|
||||
export class AppError extends Error {
|
||||
statusCode?: number;
|
||||
method?: string;
|
||||
url?: string;
|
||||
code?: string;
|
||||
context?: Record<string, any>;
|
||||
|
||||
constructor(messageOrError: string | unknown, data?: Partial<Omit<AppError, "name" | "message">>) {
|
||||
// Handle error objects - extract essential info
|
||||
const error = messageOrError;
|
||||
|
||||
// Already AppError - return immediately for performance (no need to re-process)
|
||||
if (error instanceof AppError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
// Handle string message (simple case like regular Error)
|
||||
if (typeof messageOrError === "string") {
|
||||
super(messageOrError);
|
||||
this.name = "AppError";
|
||||
if (data) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// AxiosError - extract ONLY essential info (no config, no headers, no cookies)
|
||||
if (error && typeof error === "object" && "isAxiosError" in error) {
|
||||
const axiosError = error as AxiosError;
|
||||
const responseData = axiosError.response?.data as any;
|
||||
super(responseData?.message || axiosError.message);
|
||||
this.name = "AppError";
|
||||
this.statusCode = axiosError.response?.status;
|
||||
this.method = axiosError.config?.method?.toUpperCase();
|
||||
this.url = axiosError.config?.url;
|
||||
this.code = axiosError.code;
|
||||
return;
|
||||
}
|
||||
|
||||
// DOMException (AbortError from cancelled requests)
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
super(error.message);
|
||||
this.name = "AppError";
|
||||
this.code = "ABORT_ERROR";
|
||||
return;
|
||||
}
|
||||
|
||||
// Standard Error objects
|
||||
if (error instanceof Error) {
|
||||
super(error.message);
|
||||
this.name = "AppError";
|
||||
this.code = error.name;
|
||||
return;
|
||||
}
|
||||
|
||||
// Unknown error types - safe fallback
|
||||
super("Unknown error occurred");
|
||||
this.name = "AppError";
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { DocumentCollaborativeEvents, type TDocumentEventsServer } from "@plane/
|
||||
* @param param0
|
||||
*/
|
||||
export const onStateless = async ({ payload, document }: onStatelessPayload) => {
|
||||
const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
|
||||
const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer]?.client;
|
||||
if (response) {
|
||||
document.broadcastStateless(response);
|
||||
}
|
||||
|
||||
+10
-7
@@ -59,13 +59,20 @@ export class RedisManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Configuration optimized for BOTH regular operations AND pub/sub
|
||||
// HocuspocusRedis uses .duplicate() which inherits these settings
|
||||
this.redisClient = new Redis(redisUrl, {
|
||||
lazyConnect: true,
|
||||
lazyConnect: false, // Connect immediately for reliability (duplicates inherit this)
|
||||
keepAlive: 30000,
|
||||
connectTimeout: 10000,
|
||||
commandTimeout: 5000,
|
||||
// enableOfflineQueue: false,
|
||||
maxRetriesPerRequest: 3,
|
||||
enableOfflineQueue: true, // Keep commands queued during reconnection
|
||||
retryStrategy: (times: number) => {
|
||||
// Exponential backoff with max 2 seconds
|
||||
const delay = Math.min(times * 50, 2000);
|
||||
logger.info(`REDIS_MANAGER: Reconnection attempt ${times}, delay: ${delay}ms`);
|
||||
return delay;
|
||||
},
|
||||
});
|
||||
|
||||
// Set up event listeners
|
||||
@@ -94,10 +101,6 @@ export class RedisManager {
|
||||
this.isConnected = false;
|
||||
});
|
||||
|
||||
// Connect to Redis
|
||||
await this.redisClient.connect();
|
||||
|
||||
// Test the connection
|
||||
await this.redisClient.ping();
|
||||
logger.info("REDIS_MANAGER: Redis connection test successful");
|
||||
} catch (error) {
|
||||
|
||||
@@ -39,7 +39,6 @@ export class Server {
|
||||
const manager = HocusPocusServerManager.getInstance();
|
||||
this.hocuspocusServer = await manager.initialize();
|
||||
logger.info("SERVER: HocusPocus setup completed");
|
||||
|
||||
this.setupRoutes(this.hocuspocusServer);
|
||||
this.setupNotFoundHandler();
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import { logger } from "@plane/logger";
|
||||
import { env } from "@/env";
|
||||
import { AppError } from "@/lib/errors";
|
||||
|
||||
export abstract class APIService {
|
||||
protected baseURL: string;
|
||||
@@ -21,8 +21,7 @@ export abstract class APIService {
|
||||
this.axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
logger.error("AXIOS_ERROR:", error);
|
||||
return Promise.reject(error);
|
||||
return Promise.reject(new AppError(error));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { logger } from "@plane/logger";
|
||||
import { TPage } from "@plane/types";
|
||||
// services
|
||||
import { AppError } from "@/lib/errors";
|
||||
import { APIService } from "../api.service";
|
||||
|
||||
export type TPageDescriptionPayload = {
|
||||
@@ -21,7 +23,11 @@ export abstract class PageCoreService extends APIService {
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "fetchDetails", pageId },
|
||||
});
|
||||
logger.error("Failed to fetch page details", appError);
|
||||
throw appError;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,7 +41,11 @@ export abstract class PageCoreService extends APIService {
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "fetchDescriptionBinary", pageId },
|
||||
});
|
||||
logger.error("Failed to fetch page description binary", appError);
|
||||
throw appError;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,7 +60,7 @@ export abstract class PageCoreService extends APIService {
|
||||
|
||||
// Early abort check
|
||||
if (abortSignal?.aborted) {
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
throw new AppError(new DOMException("Aborted", "AbortError"));
|
||||
}
|
||||
|
||||
// Create an abort listener that will reject the pending promise
|
||||
@@ -58,7 +68,7 @@ export abstract class PageCoreService extends APIService {
|
||||
const abortPromise = new Promise((_, reject) => {
|
||||
if (abortSignal) {
|
||||
abortListener = () => {
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
reject(new AppError(new DOMException("Aborted", "AbortError")));
|
||||
};
|
||||
abortSignal.addEventListener("abort", abortListener);
|
||||
}
|
||||
@@ -66,16 +76,22 @@ export abstract class PageCoreService extends APIService {
|
||||
|
||||
try {
|
||||
return await Promise.race([
|
||||
this.patch(`${this.basePath}/pages/${pageId}`, data, {
|
||||
this.patch(`${this.basePath}/pages/${pageId}/`, data, {
|
||||
headers: this.getHeader(),
|
||||
signal: abortSignal,
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
if (error.name === "AbortError") {
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "updatePageProperties", pageId },
|
||||
});
|
||||
|
||||
if (appError.code === "ABORT_ERROR") {
|
||||
throw appError;
|
||||
}
|
||||
throw error;
|
||||
|
||||
logger.error("Failed to update page properties", appError);
|
||||
throw appError;
|
||||
}),
|
||||
abortPromise,
|
||||
]);
|
||||
@@ -93,7 +109,11 @@ export abstract class PageCoreService extends APIService {
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "updateDescriptionBinary", pageId },
|
||||
});
|
||||
logger.error("Failed to update page description binary", appError);
|
||||
throw appError;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AppError } from "@/lib/errors";
|
||||
import type { HocusPocusServerContext, TDocumentTypes } from "@/types";
|
||||
// services
|
||||
import { ProjectPageService } from "./project-page.service";
|
||||
@@ -11,5 +12,5 @@ export const getPageService = (documentType: TDocumentTypes, context: HocusPocus
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Invalid document type ${documentType} provided.`);
|
||||
throw new AppError(`Invalid document type ${documentType} provided.`);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AppError } from "@/lib/errors";
|
||||
import { PageService } from "./extended.service";
|
||||
|
||||
interface ProjectPageServiceParams {
|
||||
@@ -13,9 +14,9 @@ export class ProjectPageService extends PageService {
|
||||
constructor(params: ProjectPageServiceParams) {
|
||||
super();
|
||||
const { workspaceSlug, projectId } = params;
|
||||
if (!workspaceSlug || !projectId) throw new Error("Missing required fields.");
|
||||
if (!workspaceSlug || !projectId) throw new AppError("Missing required fields.");
|
||||
// validate cookie
|
||||
if (!params.cookie) throw new Error("Cookie is required.");
|
||||
if (!params.cookie) throw new AppError("Cookie is required.");
|
||||
// set cookie
|
||||
this.setHeader("Cookie", params.cookie);
|
||||
// set base path
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// types
|
||||
import { logger } from "@plane/logger";
|
||||
import type { IUser } from "@plane/types";
|
||||
// services
|
||||
import { AppError } from "@/lib/errors";
|
||||
import { APIService } from "@/services/api.service";
|
||||
|
||||
export class UserService extends APIService {
|
||||
@@ -22,7 +24,11 @@ export class UserService extends APIService {
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "currentUser" },
|
||||
});
|
||||
logger.error("Failed to fetch current user", appError);
|
||||
throw appError;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+33
-19
@@ -1,8 +1,9 @@
|
||||
// eslint-disable-next-line import/order
|
||||
import { setupSentry } from "./instrument";
|
||||
setupSentry();
|
||||
|
||||
// eslint-disable-next-line import/order
|
||||
import { logger } from "@plane/logger";
|
||||
import { AppError } from "@/lib/errors";
|
||||
import { Server } from "./server";
|
||||
|
||||
let server: Server;
|
||||
@@ -20,28 +21,41 @@ async function startServer() {
|
||||
|
||||
startServer();
|
||||
|
||||
// Graceful shutdown on unhandled rejection
|
||||
process.on("unhandledRejection", async (err: Error) => {
|
||||
logger.error(`UNHANDLED REJECTION!`, err);
|
||||
// Handle process signals
|
||||
process.on("SIGTERM", async () => {
|
||||
logger.info("Received SIGTERM signal. Initiating graceful shutdown...");
|
||||
try {
|
||||
// if (server) {
|
||||
// await server.destroy();
|
||||
// }
|
||||
} finally {
|
||||
// logger.info("Exiting process...");
|
||||
// process.exit(1);
|
||||
if (server) {
|
||||
await server.destroy();
|
||||
}
|
||||
logger.info("Server shut down gracefully");
|
||||
} catch (error) {
|
||||
logger.error("Error during graceful shutdown:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Graceful shutdown on uncaught exception
|
||||
process.on("uncaughtException", async (err: Error) => {
|
||||
logger.error(`UNCAUGHT EXCEPTION!`, err);
|
||||
process.on("SIGINT", async () => {
|
||||
logger.info("Received SIGINT signal. Killing node process...");
|
||||
try {
|
||||
// if (server) {
|
||||
// await server.destroy();
|
||||
// }
|
||||
} finally {
|
||||
// logger.info("Exiting process...");
|
||||
// process.exit(1);
|
||||
if (server) {
|
||||
await server.destroy();
|
||||
}
|
||||
logger.info("Server shut down gracefully");
|
||||
} catch (error) {
|
||||
logger.error("Error during graceful shutdown:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (err: Error) => {
|
||||
const error = new AppError(err);
|
||||
logger.error(`[UNHANDLED_REJECTION]`, error);
|
||||
});
|
||||
|
||||
process.on("uncaughtException", (err: Error) => {
|
||||
const error = new AppError(err);
|
||||
logger.error(`[UNCAUGHT_EXCEPTION]`, error);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Type-safe admin commands for server-to-server communication
|
||||
*/
|
||||
|
||||
/**
|
||||
* Force close error codes - reasons why a document is being force closed
|
||||
*/
|
||||
export enum ForceCloseReason {
|
||||
CRITICAL_ERROR = "critical_error",
|
||||
MEMORY_LEAK = "memory_leak",
|
||||
DOCUMENT_TOO_LARGE = "document_too_large",
|
||||
ADMIN_REQUEST = "admin_request",
|
||||
SERVER_SHUTDOWN = "server_shutdown",
|
||||
SECURITY_VIOLATION = "security_violation",
|
||||
CORRUPTION_DETECTED = "corruption_detected",
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket close codes
|
||||
* https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code
|
||||
*/
|
||||
export enum CloseCode {
|
||||
/** Normal closure; the connection successfully completed */
|
||||
NORMAL = 1000,
|
||||
/** The endpoint is going away (server shutdown or browser navigating away) */
|
||||
GOING_AWAY = 1001,
|
||||
/** Protocol error */
|
||||
PROTOCOL_ERROR = 1002,
|
||||
/** Unsupported data */
|
||||
UNSUPPORTED_DATA = 1003,
|
||||
/** Reserved (no status code was present) */
|
||||
NO_STATUS = 1005,
|
||||
/** Abnormal closure */
|
||||
ABNORMAL = 1006,
|
||||
/** Invalid frame payload data */
|
||||
INVALID_DATA = 1007,
|
||||
/** Policy violation */
|
||||
POLICY_VIOLATION = 1008,
|
||||
/** Message too big */
|
||||
MESSAGE_TOO_BIG = 1009,
|
||||
/** Client expected extension not negotiated */
|
||||
MANDATORY_EXTENSION = 1010,
|
||||
/** Server encountered unexpected condition */
|
||||
INTERNAL_ERROR = 1011,
|
||||
/** Custom: Force close requested */
|
||||
FORCE_CLOSE = 4000,
|
||||
/** Custom: Document too large */
|
||||
DOCUMENT_TOO_LARGE = 4001,
|
||||
/** Custom: Memory pressure */
|
||||
MEMORY_PRESSURE = 4002,
|
||||
/** Custom: Security violation */
|
||||
SECURITY_VIOLATION = 4003,
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin command types
|
||||
*/
|
||||
export enum AdminCommand {
|
||||
FORCE_CLOSE = "force_close",
|
||||
HEALTH_CHECK = "health_check",
|
||||
RESTART_DOCUMENT = "restart_document",
|
||||
}
|
||||
|
||||
/**
|
||||
* Force close command data structure
|
||||
*/
|
||||
export interface ForceCloseCommandData {
|
||||
command: AdminCommand.FORCE_CLOSE;
|
||||
docId: string;
|
||||
reason: ForceCloseReason;
|
||||
code: CloseCode;
|
||||
originServer: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check command data structure
|
||||
*/
|
||||
export interface HealthCheckCommandData {
|
||||
command: AdminCommand.HEALTH_CHECK;
|
||||
originServer: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all admin commands
|
||||
*/
|
||||
export type AdminCommandData = ForceCloseCommandData | HealthCheckCommandData;
|
||||
|
||||
/**
|
||||
* Client force close message structure (sent to clients via sendStateless)
|
||||
*/
|
||||
export interface ClientForceCloseMessage {
|
||||
type: "force_close";
|
||||
reason: ForceCloseReason;
|
||||
code: CloseCode;
|
||||
message?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin command handler function type
|
||||
*/
|
||||
export type AdminCommandHandler<T extends AdminCommandData = AdminCommandData> = (data: T) => Promise<void> | void;
|
||||
|
||||
/**
|
||||
* Type guard to check if data is a ForceCloseCommandData
|
||||
*/
|
||||
export function isForceCloseCommand(data: AdminCommandData): data is ForceCloseCommandData {
|
||||
return data.command === AdminCommand.FORCE_CLOSE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if data is a HealthCheckCommandData
|
||||
*/
|
||||
export function isHealthCheckCommand(data: AdminCommandData): data is HealthCheckCommandData {
|
||||
return data.command === AdminCommand.HEALTH_CHECK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate force close reason
|
||||
*/
|
||||
export function isValidForceCloseReason(reason: string): reason is ForceCloseReason {
|
||||
return Object.values(ForceCloseReason).includes(reason as ForceCloseReason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable message for force close reason
|
||||
*/
|
||||
export function getForceCloseMessage(reason: ForceCloseReason): string {
|
||||
const messages: Record<ForceCloseReason, string> = {
|
||||
[ForceCloseReason.CRITICAL_ERROR]: "A critical error occurred. Please refresh the page.",
|
||||
[ForceCloseReason.MEMORY_LEAK]: "Memory limit exceeded. Please refresh the page.",
|
||||
[ForceCloseReason.DOCUMENT_TOO_LARGE]:
|
||||
"Content limit reached and live sync is off. Create a new page or use nested pages to continue syncing.",
|
||||
[ForceCloseReason.ADMIN_REQUEST]: "Connection closed by administrator. Please try again later.",
|
||||
[ForceCloseReason.SERVER_SHUTDOWN]: "Server is shutting down. Please reconnect in a moment.",
|
||||
[ForceCloseReason.SECURITY_VIOLATION]: "Security violation detected. Connection terminated.",
|
||||
[ForceCloseReason.CORRUPTION_DETECTED]: "Data corruption detected. Please refresh the page.",
|
||||
};
|
||||
|
||||
return messages[reason] || "Connection closed. Please refresh the page.";
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { type Hocuspocus } from "@hocuspocus/server";
|
||||
import { createRealtimeEvent } from "@plane/editor";
|
||||
import { logger } from "@plane/logger";
|
||||
import type { FetchPayloadWithContext, StorePayloadWithContext } from "@/types";
|
||||
import { broadcastMessageToPage } from "./broadcast-message";
|
||||
|
||||
// Helper to broadcast error to frontend
|
||||
export const broadcastError = async (
|
||||
hocuspocusServerInstance: Hocuspocus,
|
||||
pageId: string,
|
||||
errorMessage: string,
|
||||
errorType: "fetch" | "store",
|
||||
context: FetchPayloadWithContext["context"] | StorePayloadWithContext["context"],
|
||||
errorCode?: "content_too_large" | "page_locked" | "page_archived",
|
||||
shouldDisconnect?: boolean
|
||||
) => {
|
||||
try {
|
||||
const errorEvent = createRealtimeEvent({
|
||||
action: "error",
|
||||
page_id: pageId,
|
||||
parent_id: undefined,
|
||||
descendants_ids: [],
|
||||
data: {
|
||||
error_message: errorMessage,
|
||||
error_type: errorType,
|
||||
error_code: errorCode,
|
||||
should_disconnect: shouldDisconnect,
|
||||
user_id: context.userId || "",
|
||||
},
|
||||
workspace_slug: context.workspaceSlug || "",
|
||||
user_id: context.userId || "",
|
||||
});
|
||||
|
||||
await broadcastMessageToPage(hocuspocusServerInstance, pageId, errorEvent);
|
||||
} catch (broadcastError) {
|
||||
logger.error("Error broadcasting error message to frontend:", broadcastError);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Hocuspocus } from "@hocuspocus/server";
|
||||
import { BroadcastedEvent } from "@plane/editor";
|
||||
import { logger } from "@plane/logger";
|
||||
import { Redis } from "@/extensions/redis";
|
||||
import { AppError } from "@/lib/errors";
|
||||
|
||||
export const broadcastMessageToPage = async (
|
||||
hocuspocusServerInstance: Hocuspocus,
|
||||
documentName: string,
|
||||
eventData: BroadcastedEvent
|
||||
): Promise<boolean> => {
|
||||
if (!hocuspocusServerInstance || !hocuspocusServerInstance.documents) {
|
||||
const appError = new AppError("HocusPocus server not available or initialized", {
|
||||
context: { operation: "broadcastMessageToPage", documentName },
|
||||
});
|
||||
logger.error("Error while broadcasting message:", appError);
|
||||
return false;
|
||||
}
|
||||
|
||||
const redisExtension = hocuspocusServerInstance.configuration.extensions.find((ext) => ext instanceof Redis);
|
||||
|
||||
if (!redisExtension) {
|
||||
logger.error("BROADCAST_MESSAGE_TO_PAGE: Redis extension not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await redisExtension.broadcastToDocument(documentName, eventData);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`BROADCAST_MESSAGE_TO_PAGE: Error broadcasting to ${documentName}:`, error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1,83 +1,21 @@
|
||||
import { getSchema } from "@tiptap/core";
|
||||
import { generateHTML, generateJSON } from "@tiptap/html";
|
||||
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
// plane editor
|
||||
import {
|
||||
getAllDocumentFormatsFromDocumentEditorBinaryData,
|
||||
getAllDocumentFormatsFromRichTextEditorBinaryData,
|
||||
getBinaryDataFromDocumentEditorHTMLString,
|
||||
getBinaryDataFromRichTextEditorHTMLString,
|
||||
} from "@plane/editor";
|
||||
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@plane/editor/lib";
|
||||
// plane types
|
||||
import { TDocumentPayload } from "@plane/types";
|
||||
|
||||
const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps];
|
||||
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
|
||||
|
||||
type TArgs = {
|
||||
document_html: string;
|
||||
variant: "rich" | "document";
|
||||
};
|
||||
|
||||
export const convertHTMLDocumentToAllFormats = (args: TArgs): TDocumentPayload => {
|
||||
const { document_html, variant } = args;
|
||||
|
||||
if (variant === "rich") {
|
||||
const contentBinary = getBinaryDataFromRichTextEditorHTMLString(document_html);
|
||||
const { contentBinaryEncoded, contentHTML, contentJSON } =
|
||||
getAllDocumentFormatsFromRichTextEditorBinaryData(contentBinary);
|
||||
return {
|
||||
description: contentJSON,
|
||||
description_html: contentHTML,
|
||||
description_binary: contentBinaryEncoded,
|
||||
};
|
||||
}
|
||||
|
||||
if (variant === "document") {
|
||||
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
|
||||
const { contentBinaryEncoded, contentHTML, contentJSON } =
|
||||
getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary);
|
||||
return {
|
||||
description: contentJSON,
|
||||
description_html: contentHTML,
|
||||
description_binary: contentBinaryEncoded,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Invalid variant provided: ${variant}`);
|
||||
};
|
||||
|
||||
export const getAllDocumentFormatsFromBinaryData = (
|
||||
description: Uint8Array
|
||||
): {
|
||||
contentBinaryEncoded: string;
|
||||
contentJSON: object;
|
||||
contentHTML: string;
|
||||
} => {
|
||||
// encode binary description data
|
||||
const base64Data = Buffer.from(description).toString("base64");
|
||||
const yDoc = new Y.Doc();
|
||||
Y.applyUpdate(yDoc, description);
|
||||
// convert to JSON
|
||||
const type = yDoc.getXmlFragment("default");
|
||||
const contentJSON = yXmlFragmentToProseMirrorRootNode(type, documentEditorSchema).toJSON();
|
||||
// convert to HTML
|
||||
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
|
||||
|
||||
export const generateTitleProsemirrorJson = (text: string) => {
|
||||
return {
|
||||
contentBinaryEncoded: base64Data,
|
||||
contentJSON,
|
||||
contentHTML,
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 1 },
|
||||
...(text
|
||||
? {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export const getBinaryDataFromHTMLString = (descriptionHTML: string): Uint8Array => {
|
||||
// convert HTML to JSON
|
||||
const contentJSON = generateJSON(descriptionHTML ?? "<p></p>", DOCUMENT_EDITOR_EXTENSIONS);
|
||||
// convert JSON to Y.Doc format
|
||||
const transformedData = prosemirrorJSONToYDoc(documentEditorSchema, contentJSON, "default");
|
||||
// convert Y.Doc to Uint8Array format
|
||||
return Y.encodeStateAsUpdate(transformedData);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
export const addSpaceIfCamelCase = (str: string) => str.replace(/([a-z])([A-Z])/g, "$1 $2");
|
||||
|
||||
const fallbackCopyTextToClipboard = (text: string) => {
|
||||
@@ -50,13 +48,6 @@ export const checkEmailValidity = (email: string): boolean => {
|
||||
return isEmailValid;
|
||||
};
|
||||
|
||||
export const isEmptyHtmlString = (htmlString: string, allowedHTMLTags: string[] = []) => {
|
||||
// Remove HTML tags using regex
|
||||
const cleanText = DOMPurify.sanitize(htmlString, { ALLOWED_TAGS: allowedHTMLTags });
|
||||
// Trim the string and check if it's empty
|
||||
return cleanText.trim() === "";
|
||||
};
|
||||
|
||||
export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " ");
|
||||
|
||||
export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "space",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@@ -31,7 +31,6 @@
|
||||
"axios": "catalog:",
|
||||
"clsx": "^2.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.0.11",
|
||||
"dotenv": "^16.3.1",
|
||||
"lodash-es": "catalog:",
|
||||
"lowlight": "^2.9.0",
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["@plane/eslint-config/next.js"],
|
||||
rules: {
|
||||
"no-duplicate-imports": "off",
|
||||
"import/no-duplicates": ["error", { "prefer-inline": false }],
|
||||
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
|
||||
"@typescript-eslint/no-import-type-side-effects": "error",
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
{
|
||||
prefer: "type-imports",
|
||||
fixStyle: "separate-type-imports",
|
||||
disallowTypeAnnotations: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
import { FC, useState } from "react";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { SIDEBAR_WIDTH } from "@plane/constants";
|
||||
|
||||
@@ -6,7 +6,8 @@ import { useRouter } from "next/navigation";
|
||||
// plane package imports
|
||||
import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { type TabItem, Tabs } from "@plane/ui";
|
||||
import { Tabs } from "@plane/ui";
|
||||
import type { TabItem } from "@plane/ui";
|
||||
// components
|
||||
import AnalyticsFilterActions from "@/components/analytics/analytics-filter-actions";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
|
||||
@@ -17,7 +17,7 @@ import { SidebarProjectsListItem } from "@/components/workspace/sidebar/projects
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { TProject } from "@/plane-web/types";
|
||||
import type { TProject } from "@/plane-web/types";
|
||||
import { ExtendedSidebarWrapper } from "./extended-sidebar-wrapper";
|
||||
|
||||
export const ExtendedProjectSidebar = observer(() => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { FC } from "react";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { EXTENDED_SIDEBAR_WIDTH, SIDEBAR_WIDTH } from "@plane/constants";
|
||||
|
||||
@@ -5,7 +5,7 @@ import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS } from "@plane/constants";
|
||||
import { EUserWorkspaceRoles } from "@plane/types";
|
||||
import type { EUserWorkspaceRoles } from "@plane/types";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
|
||||
// ui
|
||||
import { FC } from "react";
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { ChevronDown, PanelRight } from "lucide-react";
|
||||
import { PROFILE_VIEWER_TAB, PROFILE_ADMINS_TAB, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { YourWorkIcon } from "@plane/propel/icons";
|
||||
import { IUserProfileProjectSegregation } from "@plane/types";
|
||||
import type { IUserProfileProjectSegregation } from "@plane/types";
|
||||
import { Breadcrumbs, Header, CustomMenu } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
|
||||
@@ -10,13 +10,13 @@ import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "
|
||||
// plane i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// types
|
||||
import {
|
||||
EIssuesStoreType,
|
||||
import type {
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
TIssueLayouts,
|
||||
EIssueLayoutTypes,
|
||||
} from "@plane/types";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
// ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// components
|
||||
|
||||
@@ -5,7 +5,7 @@ import useSWR from "swr";
|
||||
// plane imports
|
||||
import { GROUP_CHOICES } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IUserStateDistribution, TStateGroups } from "@plane/types";
|
||||
import type { IUserStateDistribution, TStateGroups } from "@plane/types";
|
||||
import { ContentWrapper } from "@plane/ui";
|
||||
// components
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ArchiveIcon, CycleIcon, ModuleIcon, WorkItemsIcon } from "@plane/propel/icons";
|
||||
|
||||
+2
-7
@@ -19,13 +19,8 @@ import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { CycleIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import {
|
||||
EIssuesStoreType,
|
||||
ICustomSearchSelectOption,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
EIssueLayoutTypes,
|
||||
} from "@plane/types";
|
||||
import type { ICustomSearchSelectOption, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
|
||||
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
|
||||
import { Breadcrumbs, BreadcrumbNavigationSearchDropdown, Header } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
|
||||
+2
-1
@@ -7,7 +7,8 @@ import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
|
||||
// plane imports
|
||||
import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EIssuesStoreType, IIssueDisplayFilterOptions, IIssueDisplayProperties, EIssueLayoutTypes } from "@plane/types";
|
||||
import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, EIssueLayoutTypes } from "@plane/types";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// components
|
||||
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// ui
|
||||
|
||||
+3
-2
@@ -2,9 +2,10 @@
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { GanttChartSquare, LayoutGrid, List, type LucideIcon } from "lucide-react";
|
||||
import { GanttChartSquare, LayoutGrid, List } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
// plane package imports
|
||||
import { TCycleLayoutOptions } from "@plane/types";
|
||||
import type { TCycleLayoutOptions } from "@plane/types";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// hooks
|
||||
import { useCycleFilter } from "@/hooks/store/use-cycle-filter";
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@ import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { EUserPermissionsLevel, CYCLE_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EUserProjectRoles, TCycleFilters } from "@plane/types";
|
||||
import type { TCycleFilters } from "@plane/types";
|
||||
import { EUserProjectRoles } from "@plane/types";
|
||||
// components
|
||||
import { Header, EHeaderVariant } from "@plane/ui";
|
||||
import { calculateTotalFilters } from "@plane/utils";
|
||||
|
||||
+2
-1
@@ -7,7 +7,8 @@ import { ChevronDown } from "lucide-react";
|
||||
// plane imports
|
||||
import { EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EIssuesStoreType, IIssueDisplayFilterOptions, IIssueDisplayProperties, EIssueLayoutTypes } from "@plane/types";
|
||||
import type { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
|
||||
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
|
||||
// components
|
||||
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
|
||||
import {
|
||||
|
||||
+2
-7
@@ -17,13 +17,8 @@ import {
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { ModuleIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import {
|
||||
EIssuesStoreType,
|
||||
ICustomSearchSelectOption,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
EIssueLayoutTypes,
|
||||
} from "@plane/types";
|
||||
import type { ICustomSearchSelectOption, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
|
||||
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
|
||||
import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
|
||||
+2
-1
@@ -8,7 +8,8 @@ import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
|
||||
// plane imports
|
||||
import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EIssuesStoreType, IIssueDisplayFilterOptions, IIssueDisplayProperties, EIssueLayoutTypes } from "@plane/types";
|
||||
import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, EIssueLayoutTypes } from "@plane/types";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// components
|
||||
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@ import { useParams } from "next/navigation";
|
||||
// types
|
||||
import { EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EUserProjectRoles, TModuleFilters } from "@plane/types";
|
||||
import type { TModuleFilters } from "@plane/types";
|
||||
import { EUserProjectRoles } from "@plane/types";
|
||||
// components
|
||||
import { calculateTotalFilters } from "@plane/utils";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
|
||||
+4
-2
@@ -7,7 +7,8 @@ import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane types
|
||||
import { getButtonStyling } from "@plane/propel/button";
|
||||
import { EFileAssetType, TSearchEntityRequestPayload, TWebhookConnectionQueryParams } from "@plane/types";
|
||||
import type { TSearchEntityRequestPayload, TWebhookConnectionQueryParams } from "@plane/types";
|
||||
import { EFileAssetType } from "@plane/types";
|
||||
// plane ui
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
@@ -15,7 +16,8 @@ import { cn } from "@plane/utils";
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { IssuePeekOverview } from "@/components/issues/peek-overview";
|
||||
import { PageRoot, TPageRootConfig, TPageRootHandlers } from "@/components/pages/editor/page-root";
|
||||
import type { TPageRootConfig, TPageRootHandlers } from "@/components/pages/editor/page-root";
|
||||
import { PageRoot } from "@/components/pages/editor/page-root";
|
||||
// hooks
|
||||
import { useEditorConfig } from "@/hooks/editor";
|
||||
import { useEditorAsset } from "@/hooks/store/use-editor-asset";
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { useParams } from "next/navigation";
|
||||
import { EProjectFeatureKey } from "@plane/constants";
|
||||
import { PageIcon } from "@plane/propel/icons";
|
||||
// types
|
||||
import { ICustomSearchSelectOption } from "@plane/types";
|
||||
import type { ICustomSearchSelectOption } from "@plane/types";
|
||||
// ui
|
||||
import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
|
||||
// components
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ import {
|
||||
// plane types
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { TPage } from "@plane/types";
|
||||
import type { TPage } from "@plane/types";
|
||||
// plane ui
|
||||
import { Breadcrumbs, Header } from "@plane/ui";
|
||||
// helpers
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
// components
|
||||
import { AppHeader } from "@/components/core/app-header";
|
||||
import { ContentWrapper } from "@/components/core/content-wrapper";
|
||||
|
||||
+2
-1
@@ -5,7 +5,8 @@ import { useParams, useSearchParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EUserProjectRoles, TPageNavigationTabs } from "@plane/types";
|
||||
import type { TPageNavigationTabs } from "@plane/types";
|
||||
import { EUserProjectRoles } from "@plane/types";
|
||||
// components
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
|
||||
|
||||
+2
-8
@@ -17,14 +17,8 @@ import {
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { ViewsIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import {
|
||||
EIssuesStoreType,
|
||||
EViewAccess,
|
||||
ICustomSearchSelectOption,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
EIssueLayoutTypes,
|
||||
} from "@plane/types";
|
||||
import type { ICustomSearchSelectOption, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
|
||||
import { EIssuesStoreType, EViewAccess, EIssueLayoutTypes } from "@plane/types";
|
||||
// ui
|
||||
import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
|
||||
// components
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@ import { useParams } from "next/navigation";
|
||||
// components
|
||||
import { EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EUserProjectRoles, EViewAccess, TViewFilterProps } from "@plane/types";
|
||||
import type { EViewAccess, TViewFilterProps } from "@plane/types";
|
||||
import { EUserProjectRoles } from "@plane/types";
|
||||
import { Header, EHeaderVariant } from "@plane/ui";
|
||||
import { calculateTotalFilters } from "@plane/utils";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
// components
|
||||
import { AppHeader } from "@/components/core/app-header";
|
||||
import { ContentWrapper } from "@/components/core/content-wrapper";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane web layouts
|
||||
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
// components
|
||||
import { AppHeader } from "@/components/core/app-header";
|
||||
import { ContentWrapper } from "@/components/core/content-wrapper";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FC } from "react";
|
||||
import type { FC } from "react";
|
||||
import { isEmpty } from "lodash-es";
|
||||
import { observer } from "mobx-react";
|
||||
// plane helpers
|
||||
|
||||
@@ -13,13 +13,8 @@ import {
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { ViewsIcon } from "@plane/propel/icons";
|
||||
import {
|
||||
EIssuesStoreType,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
ICustomSearchSelectOption,
|
||||
EIssueLayoutTypes,
|
||||
} from "@plane/types";
|
||||
import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, ICustomSearchSelectOption } from "@plane/types";
|
||||
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
|
||||
import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { FC, ReactNode } from "react";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
// constants
|
||||
import { WORKSPACE_SETTINGS_ACCESS } from "@plane/constants";
|
||||
import { EUserWorkspaceRoles } from "@plane/types";
|
||||
import type { EUserWorkspaceRoles } from "@plane/types";
|
||||
// components
|
||||
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
|
||||
import { getWorkspaceActivePath, pathnameToAccessKey } from "@/components/settings/helper";
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { IWorkspaceBulkInviteFormData } from "@plane/types";
|
||||
import type { IWorkspaceBulkInviteFormData } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
EUserPermissions,
|
||||
WORKSPACE_SETTINGS_CATEGORY,
|
||||
} from "@plane/constants";
|
||||
import { EUserWorkspaceRoles } from "@plane/types";
|
||||
import type { EUserWorkspaceRoles } from "@plane/types";
|
||||
import { SettingsSidebar } from "@/components/settings/sidebar";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper";
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { EUserPermissions, EUserPermissionsLevel, WORKSPACE_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { IWebhook } from "@plane/types";
|
||||
import type { IWebhook } from "@plane/types";
|
||||
// ui
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
// components
|
||||
|
||||
@@ -15,7 +15,8 @@ import { getPasswordStrength } from "@plane/utils";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header";
|
||||
// helpers
|
||||
import { authErrorHandler, type EAuthenticationErrorCodes } from "@/helpers/authentication.helper";
|
||||
import { authErrorHandler } from "@/helpers/authentication.helper";
|
||||
import type { EAuthenticationErrorCodes } from "@/helpers/authentication.helper";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
// services
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { useParams } from "next/navigation";
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { IProject } from "@plane/types";
|
||||
import type { IProject } from "@plane/types";
|
||||
// ui
|
||||
// components
|
||||
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
|
||||
|
||||
+1
-1
@@ -6,11 +6,11 @@ import { useParams } from "next/navigation";
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { ProjectFeaturesList } from "@/components/project/settings/features-list";
|
||||
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { ProjectFeaturesList } from "@/plane-web/components/projects/settings/features-list";
|
||||
|
||||
const FeaturesSettingsPage = observer(() => {
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useEffect } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
// components
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Metadata } from "next";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Forgot Password - Plane",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Metadata } from "next";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Reset Password - Plane",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Metadata } from "next";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Set Password - Plane",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Metadata } from "next";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Workspace",
|
||||
|
||||
@@ -8,7 +8,7 @@ import Link from "next/link";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button, getButtonStyling } from "@plane/propel/button";
|
||||
import { PlaneLogo } from "@plane/propel/icons";
|
||||
import { IWorkspace } from "@plane/types";
|
||||
import type { IWorkspace } from "@plane/types";
|
||||
// components
|
||||
import { CreateWorkspaceForm } from "@/components/workspace/create-workspace-form";
|
||||
// hooks
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Metadata } from "next";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Invitations",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Metadata, Viewport } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
|
||||
import { PreloadResources } from "./layout.preload";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Metadata } from "next";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Onboarding",
|
||||
|
||||
@@ -4,10 +4,11 @@ import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
// plane imports
|
||||
import { I_THEME_OPTION, THEME_OPTIONS } from "@plane/constants";
|
||||
import type { I_THEME_OPTION } from "@plane/constants";
|
||||
import { THEME_OPTIONS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import { IUserTheme } from "@plane/types";
|
||||
import type { IUserTheme } from "@plane/types";
|
||||
// components
|
||||
import { applyTheme, unsetCustomCssVariables } from "@plane/utils";
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
// components
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
// wrappers
|
||||
|
||||
@@ -16,7 +16,8 @@ import { PageHead } from "@/components/core/page-title";
|
||||
import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header";
|
||||
import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper";
|
||||
// helpers
|
||||
import { authErrorHandler, type EAuthenticationErrorCodes } from "@/helpers/authentication.helper";
|
||||
import { authErrorHandler } from "@/helpers/authentication.helper";
|
||||
import type { EAuthenticationErrorCodes } from "@/helpers/authentication.helper";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
// services
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Metadata } from "next";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Sign up - Plane",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Metadata } from "next";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Workspace Invitations",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Metadata, Viewport } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
robots: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Metadata, Viewport } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import Script from "next/script";
|
||||
|
||||
// styles
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Metadata } from "next";
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
// ui
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { FC, ReactNode } from "react";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { AppProgressProvider as ProgressProvider } from "@bprogress/next";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useTheme, ThemeProvider } from "next-themes";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AnalyticsTab } from "@plane/types";
|
||||
import type { AnalyticsTab } from "@plane/types";
|
||||
import { Overview } from "@/components/analytics/overview";
|
||||
import { WorkItems } from "@/components/analytics/work-items";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { FC } from "react";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
|
||||
export type TCustomAutomationsRootProps = {
|
||||
projectId: string;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import type { FC } from "react";
|
||||
// plane imports
|
||||
import { EProjectFeatureKey } from "@plane/constants";
|
||||
import type { EProjectFeatureKey } from "@plane/constants";
|
||||
// local components
|
||||
import { ProjectBreadcrumb } from "./project";
|
||||
import { ProjectFeatureBreadcrumb } from "./project-feature";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user