fix: api server file formatting (#5054)

* fix: api server file formatting

* fix: ruff fixable errors
This commit is contained in:
sriram veeraghanta
2025-12-11 00:38:47 +05:30
committed by GitHub
parent d905345fda
commit 7109ed1fcb
376 changed files with 2784 additions and 7226 deletions
+9 -27
View File
@@ -63,9 +63,7 @@ class IssueSerializer(BaseSerializer):
type_id = serializers.PrimaryKeyRelatedField(
source="type", queryset=IssueType.objects.all(), required=False, allow_null=True
)
parent = serializers.PrimaryKeyRelatedField(
queryset=Issue.objects.all(), required=False, allow_null=True
)
parent = serializers.PrimaryKeyRelatedField(queryset=Issue.objects.all(), required=False, allow_null=True)
class Meta:
model = Issue
@@ -243,9 +241,7 @@ class IssueSerializer(BaseSerializer):
if assignees is not None:
# Get the current assignees
current_assignees = IssueAssignee.objects.filter(
issue=instance
).values_list("assignee_id", flat=True)
current_assignees = IssueAssignee.objects.filter(issue=instance).values_list("assignee_id", flat=True)
# Get the assignees to add
assignees_to_add = list(set(assignees) - set(current_assignees))
@@ -254,9 +250,7 @@ class IssueSerializer(BaseSerializer):
assignees_to_remove = list(set(current_assignees) - set(assignees))
# Delete the assignees to remove
IssueAssignee.objects.filter(
issue=instance, assignee_id__in=assignees_to_remove
).delete()
IssueAssignee.objects.filter(issue=instance, assignee_id__in=assignees_to_remove).delete()
try:
IssueAssignee.objects.bulk_create(
@@ -279,9 +273,7 @@ class IssueSerializer(BaseSerializer):
if labels is not None:
# Get the current labels
current_labels = IssueLabel.objects.filter(issue=instance).values_list(
"label_id", flat=True
)
current_labels = IssueLabel.objects.filter(issue=instance).values_list("label_id", flat=True)
# Get the labels to add
labels_to_add = list(set(labels) - set(current_labels))
@@ -290,9 +282,7 @@ class IssueSerializer(BaseSerializer):
labels_to_remove = list(set(current_labels) - set(labels))
# Delete the labels to remove
IssueLabel.objects.filter(
issue=instance, label_id__in=labels_to_remove
).delete()
IssueLabel.objects.filter(issue=instance, label_id__in=labels_to_remove).delete()
# Create the labels to add
try:
@@ -848,18 +838,12 @@ class IssueRelationSerializer(BaseSerializer):
"""
id = serializers.UUIDField(source="related_issue.id", read_only=True)
project_id = serializers.PrimaryKeyRelatedField(
source="related_issue.project_id", read_only=True
)
sequence_id = serializers.IntegerField(
source="related_issue.sequence_id", read_only=True
)
project_id = serializers.PrimaryKeyRelatedField(source="related_issue.project_id", read_only=True)
sequence_id = serializers.IntegerField(source="related_issue.sequence_id", read_only=True)
name = serializers.CharField(source="related_issue.name", read_only=True)
type_id = serializers.UUIDField(source="related_issue.type.id", read_only=True)
relation_type = serializers.CharField(read_only=True)
is_epic = serializers.BooleanField(
source="related_issue.type.is_epic", read_only=True
)
is_epic = serializers.BooleanField(source="related_issue.type.is_epic", read_only=True)
state_id = serializers.UUIDField(source="related_issue.state.id", read_only=True)
priority = serializers.CharField(source="related_issue.priority", read_only=True)
@@ -899,9 +883,7 @@ class RelatedIssueSerializer(BaseSerializer):
"""
id = serializers.UUIDField(source="issue.id", read_only=True)
project_id = serializers.PrimaryKeyRelatedField(
source="issue.project_id", read_only=True
)
project_id = serializers.PrimaryKeyRelatedField(source="issue.project_id", read_only=True)
sequence_id = serializers.IntegerField(source="issue.sequence_id", read_only=True)
name = serializers.CharField(source="issue.name", read_only=True)
type_id = serializers.UUIDField(source="issue.type.id", read_only=True)
+1 -1
View File
@@ -25,7 +25,7 @@ from plane.ee.models import WorkitemTemplate, ProjectFeature
from plane.payment.flags.flag import FeatureFlag
class ProjectCreateSerializer(BaseSerializer):
class ProjectCreateSerializer(BaseSerializer):
"""
Serializer for creating projects with workspace validation.
+1 -1
View File
@@ -15,4 +15,4 @@ router.register(r"invitations", WorkspaceInvitationsViewset, basename="workspace
# Wrap the router URLs with the workspace slug path
urlpatterns = [
path("workspaces/<str:slug>/", include(router.urls)),
]
]
+1 -1
View File
@@ -1,4 +1,4 @@
from django.urls import path, include
from django.urls import path
from plane.api.views import TeamspaceViewSet
-1
View File
@@ -8,4 +8,3 @@ urlpatterns = [
name="workspace-features",
),
]
+41 -115
View File
@@ -59,9 +59,7 @@ class CustomerAPIEndpoint(BaseAPIView):
return (
Customer.objects.filter(workspace__slug=self.kwargs.get("slug"))
.annotate(
customer_request_count=CustomerRequest.objects.filter(
customer_id=OuterRef("id")
)
customer_request_count=CustomerRequest.objects.filter(customer_id=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="count"))
.values("count")
@@ -122,9 +120,7 @@ class CustomerAPIEndpoint(BaseAPIView):
"""
workspace = Workspace.objects.get(slug=slug)
serializer = CustomerSerializer(
data=request.data, context={"workspace_id": workspace.id}
)
serializer = CustomerSerializer(data=request.data, context={"workspace_id": workspace.id})
if serializer.is_valid():
serializer.save(workspace_id=workspace.id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@@ -146,9 +142,7 @@ class CustomerDetailAPIEndpoint(BaseAPIView):
return (
Customer.objects.filter(workspace__slug=self.kwargs.get("slug"))
.annotate(
customer_request_count=CustomerRequest.objects.filter(
customer_id=OuterRef("id")
)
customer_request_count=CustomerRequest.objects.filter(customer_id=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="count"))
.values("count")
@@ -246,9 +240,7 @@ class CustomerRequestAPIEndpoint(BaseAPIView):
def get_queryset(self, customer_id):
return (
CustomerRequest.objects.filter(
customer_id=customer_id, workspace__slug=self.kwargs.get("slug")
)
CustomerRequest.objects.filter(customer_id=customer_id, workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "customer")
.order_by(self.kwargs.get("order_by", "-created_at"))
).distinct()
@@ -283,9 +275,7 @@ class CustomerRequestAPIEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=request_queryset,
on_results=lambda requests: CustomerRequestSerializer(
requests, many=True
).data,
on_results=lambda requests: CustomerRequestSerializer(requests, many=True).data,
)
@extend_schema(
@@ -331,9 +321,7 @@ class CustomerRequestDetailAPIEndpoint(BaseAPIView):
def get_queryset(self, customer_id):
return (
CustomerRequest.objects.filter(
customer_id=customer_id, workspace__slug=self.kwargs.get("slug")
)
CustomerRequest.objects.filter(customer_id=customer_id, workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "customer")
.order_by(self.kwargs.get("order_by", "-created_at"))
).distinct()
@@ -385,13 +373,9 @@ class CustomerRequestDetailAPIEndpoint(BaseAPIView):
Partially update an existing customer request.
"""
customer_request = CustomerRequest.objects.get(
pk=pk, customer_id=customer_id, workspace__slug=slug
)
customer_request = CustomerRequest.objects.get(pk=pk, customer_id=customer_id, workspace__slug=slug)
serializer = CustomerRequestSerializer(
customer_request, data=request.data, partial=True
)
serializer = CustomerRequestSerializer(customer_request, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
@@ -414,9 +398,7 @@ class CustomerRequestDetailAPIEndpoint(BaseAPIView):
Permanently delete a customer request and unlink any associated issues.
"""
customer_request = CustomerRequest.objects.get(
pk=pk, customer_id=customer_id, workspace__slug=slug
)
customer_request = CustomerRequest.objects.get(pk=pk, customer_id=customer_id, workspace__slug=slug)
customer_request.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -484,9 +466,7 @@ class CustomerIssuesAPIEndpoint(BaseAPIView):
# Filter by specific customer request if provided
if customer_request_id:
issues = issues.filter(
customer_request_issues__customer_request_id=customer_request_id
)
issues = issues.filter(customer_request_issues__customer_request_id=customer_request_id)
# Add search functionality
if search:
@@ -570,9 +550,7 @@ class CustomerIssuesAPIEndpoint(BaseAPIView):
# Verify customer request exists if provided
if customer_request_id:
CustomerRequest.objects.get(
pk=customer_request_id, customer_id=customer_id, workspace__slug=slug
)
CustomerRequest.objects.get(pk=customer_request_id, customer_id=customer_id, workspace__slug=slug)
# Validate that all issues exist before creating links
existing_issues = Issue.objects.filter(
@@ -729,9 +707,7 @@ class CustomerPropertiesAPIEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=properties_queryset,
on_results=lambda properties: CustomerPropertySerializer(
properties, many=True
).data,
on_results=lambda properties: CustomerPropertySerializer(properties, many=True).data,
)
@extend_schema(
@@ -757,14 +733,9 @@ class CustomerPropertiesAPIEndpoint(BaseAPIView):
options = request.data.pop("options", [])
# Basic validation
if (
not request.data.get("is_multi")
and len(request.data.get("default_value", [])) > 1
):
if not request.data.get("is_multi") and len(request.data.get("default_value", [])) > 1:
return Response(
{
"error": "Default value must be a single value for non-multi properties"
},
{"error": "Default value must be a single value for non-multi properties"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -775,9 +746,7 @@ class CustomerPropertiesAPIEndpoint(BaseAPIView):
serializer = CustomerPropertySerializer(data=request.data)
if serializer.is_valid():
# Check for duplicate name
if CustomerProperty.objects.filter(
workspace_id=workspace.id, name=request.data["name"]
).exists():
if CustomerProperty.objects.filter(workspace_id=workspace.id, name=request.data["name"]).exists():
return Response(
{"error": "Property with this name already exists in workspace"},
status=status.HTTP_400_BAD_REQUEST,
@@ -796,9 +765,7 @@ class CustomerPropertiesAPIEndpoint(BaseAPIView):
property_options = CustomerPropertyOption.objects.filter(
property_id=customer_property.id, workspace__slug=slug
)
response_data["options"] = CustomerPropertyOptionSerializer(
property_options, many=True
).data
response_data["options"] = CustomerPropertyOptionSerializer(property_options, many=True).data
return Response(response_data, status=status.HTTP_201_CREATED)
@@ -809,9 +776,9 @@ class CustomerPropertiesAPIEndpoint(BaseAPIView):
workspace_id = customer_property.workspace_id
customer_property_id = customer_property.id
last_id = CustomerPropertyOption.objects.filter(
property_id=customer_property_id
).aggregate(largest=Max("sort_order"))["largest"]
last_id = CustomerPropertyOption.objects.filter(property_id=customer_property_id).aggregate(
largest=Max("sort_order")
)["largest"]
sort_order = (last_id + 10000) if last_id else 10000
@@ -870,12 +837,8 @@ class CustomerPropertyDetailAPIEndpoint(BaseAPIView):
# Include options for OPTION type properties
response_data = CustomerPropertySerializer(customer_property).data
if customer_property.property_type == "OPTION":
options = CustomerPropertyOption.objects.filter(
property_id=customer_property.id, workspace__slug=slug
)
response_data["options"] = CustomerPropertyOptionSerializer(
options, many=True
).data
options = CustomerPropertyOption.objects.filter(property_id=customer_property.id, workspace__slug=slug)
response_data["options"] = CustomerPropertyOptionSerializer(options, many=True).data
return Response(response_data, status=status.HTTP_200_OK)
@@ -913,14 +876,9 @@ class CustomerPropertyDetailAPIEndpoint(BaseAPIView):
)
# Validation for default values
if (
not customer_property.is_multi
and len(request.data.get("default_value", [])) > 1
):
if not customer_property.is_multi and len(request.data.get("default_value", [])) > 1:
return Response(
{
"error": "Default value must be a single value for non-multi properties"
},
{"error": "Default value must be a single value for non-multi properties"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -928,9 +886,7 @@ class CustomerPropertyDetailAPIEndpoint(BaseAPIView):
if request.data.get("is_required", customer_property.is_required):
request.data["default_value"] = []
serializer = CustomerPropertySerializer(
customer_property, data=request.data, partial=True
)
serializer = CustomerPropertySerializer(customer_property, data=request.data, partial=True)
if serializer.is_valid():
# Check for duplicate name (if updating)
@@ -943,9 +899,7 @@ class CustomerPropertyDetailAPIEndpoint(BaseAPIView):
).exists()
):
return Response(
{
"error": "Property with this display name already exists in the workspace"
},
{"error": "Property with this display name already exists in the workspace"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -961,9 +915,7 @@ class CustomerPropertyDetailAPIEndpoint(BaseAPIView):
property_options = CustomerPropertyOption.objects.filter(
property_id=customer_property.id, workspace__slug=slug
)
response_data["options"] = CustomerPropertyOptionSerializer(
property_options, many=True
).data
response_data["options"] = CustomerPropertyOptionSerializer(property_options, many=True).data
return Response(response_data, status=status.HTTP_200_OK)
@@ -993,9 +945,9 @@ class CustomerPropertyDetailAPIEndpoint(BaseAPIView):
workspace_id = customer_property.workspace_id
customer_property_id = customer_property.id
last_id = CustomerPropertyOption.objects.filter(
property_id=customer_property_id
).aggregate(largest=Max("sort_order"))["largest"]
last_id = CustomerPropertyOption.objects.filter(property_id=customer_property_id).aggregate(
largest=Max("sort_order")
)["largest"]
sort_order = (last_id + 10000) if last_id else 10000
@@ -1023,9 +975,7 @@ class CustomerPropertyDetailAPIEndpoint(BaseAPIView):
property_id=customer_property.id,
pk=option["id"],
)
option_serializer = CustomerPropertyOptionSerializer(
property_option, data=option, partial=True
)
option_serializer = CustomerPropertyOptionSerializer(property_option, data=option, partial=True)
if option_serializer.is_valid():
option_serializer.save()
else:
@@ -1125,15 +1075,10 @@ class CustomerPropertyValuesAPIEndpoint(BaseAPIView):
)
# Annotate and get values
property_values = self._query_annotator(queryset).values(
"property_id", "values"
)
property_values = self._query_annotator(queryset).values("property_id", "values")
# Create response dictionary
response = {
str(prop_value["property_id"]): prop_value["values"]
for prop_value in property_values
}
response = {str(prop_value["property_id"]): prop_value["values"] for prop_value in property_values}
return Response(response, status=status.HTTP_200_OK)
@@ -1185,9 +1130,7 @@ class CustomerPropertyValuesAPIEndpoint(BaseAPIView):
customer_id=customer_id,
)
existing_prop_values = self._query_annotator(existing_prop_queryset).values(
"property_id", "values"
)
existing_prop_values = self._query_annotator(existing_prop_queryset).values("property_id", "values")
# Get customer properties
customer_properties = CustomerProperty.objects.filter(
@@ -1212,14 +1155,10 @@ class CustomerPropertyValuesAPIEndpoint(BaseAPIView):
)
# Delete old values
existing_prop_queryset.filter(
property_id__in=customer_property_ids
).delete()
existing_prop_queryset.filter(property_id__in=customer_property_ids).delete()
# Bulk create new values
CustomerPropertyValue.objects.bulk_create(
bulk_customer_property_values, batch_size=10
)
CustomerPropertyValue.objects.bulk_create(bulk_customer_property_values, batch_size=10)
return Response(status=status.HTTP_201_CREATED)
@@ -1326,15 +1265,10 @@ class CustomerPropertyValueDetailAPIEndpoint(BaseAPIView):
)
# Annotate and get values
property_values = self._query_annotator(queryset).values(
"property_id", "values"
)
property_values = self._query_annotator(queryset).values("property_id", "values")
# Create response dictionary
response = {
str(prop_value["property_id"]): prop_value["values"]
for prop_value in property_values
}
response = {str(prop_value["property_id"]): prop_value["values"] for prop_value in property_values}
return Response(response, status=status.HTTP_200_OK)
@@ -1372,9 +1306,7 @@ class CustomerPropertyValueDetailAPIEndpoint(BaseAPIView):
try:
# Verify customer and property exist
Customer.objects.get(pk=customer_id, workspace__slug=slug)
customer_property = CustomerProperty.objects.get(
workspace__slug=slug, pk=property_id
)
customer_property = CustomerProperty.objects.get(workspace__slug=slug, pk=property_id)
existing_prop_queryset = CustomerPropertyValue.objects.filter(
workspace__slug=slug, customer_id=customer_id, property_id=property_id
@@ -1383,13 +1315,9 @@ class CustomerPropertyValueDetailAPIEndpoint(BaseAPIView):
values = request.data.get("values", [])
# Check if property is required
if customer_property.is_required and (
not values or not [v for v in values if v]
):
if customer_property.is_required and (not values or not [v for v in values if v]):
return Response(
{
"error": f"{customer_property.display_name} is a required property"
},
{"error": f"{customer_property.display_name} is a required property"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -1414,9 +1342,7 @@ class CustomerPropertyValueDetailAPIEndpoint(BaseAPIView):
# Delete old values and create new ones
existing_prop_queryset.delete()
CustomerPropertyValue.objects.bulk_create(
property_values, batch_size=10
)
CustomerPropertyValue.objects.bulk_create(property_values, batch_size=10)
else:
raise ValidationError("Invalid property type")
+25 -74
View File
@@ -199,9 +199,7 @@ 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,9 +256,7 @@ 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),
@@ -306,17 +302,10 @@ 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")
@@ -359,9 +348,7 @@ 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,
)
@@ -509,9 +496,7 @@ 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(
@@ -524,20 +509,14 @@ 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")
@@ -545,9 +524,7 @@ 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()
):
@@ -604,11 +581,7 @@ 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",
@@ -628,9 +601,7 @@ 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)
@@ -768,9 +739,7 @@ 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(
@@ -789,9 +758,7 @@ 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"},
@@ -822,9 +789,7 @@ 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)
@@ -887,9 +852,7 @@ 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()
@@ -926,9 +889,7 @@ 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(
@@ -962,9 +923,7 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
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(
@@ -976,13 +935,9 @@ 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))
@@ -1072,9 +1027,7 @@ 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()),
@@ -1150,9 +1103,7 @@ 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(
@@ -1269,7 +1220,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
{"error": "New Cycle Id is required"},
status=status.HTTP_400_BAD_REQUEST,
)
old_cycle = Cycle.objects.get(
workspace__slug=slug,
project_id=project_id,
@@ -1291,7 +1242,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
request=request,
user_id=self.request.user.id,
)
# Handle the result
if result.get("success"):
return Response({"message": "Success"}, status=status.HTTP_200_OK)
+5 -16
View File
@@ -99,7 +99,7 @@ class WorkspaceMemberAPIEndpoint(BaseAPIView):
class ProjectMemberSiloEndpoint(BaseAPIView):
#TODO: Remove this endpoint once the silo is updated to use the new endpoint
# TODO: Remove this endpoint once the silo is updated to use the new endpoint
permission_classes = [ProjectMemberPermission]
use_read_replica = True
@@ -150,10 +150,7 @@ class ProjectMemberSiloEndpoint(BaseAPIView):
def post(self, request, slug, project_id):
# ------------------- Validation -------------------
if (
request.data.get("email") is None
or request.data.get("display_name") is None
):
if request.data.get("email") is None or request.data.get("display_name") is None:
return Response(
{
"error": "Expected email, display_name, workspace_slug, project_id, one or more of the fields are missing."
@@ -166,9 +163,7 @@ class ProjectMemberSiloEndpoint(BaseAPIView):
try:
validate_email(email)
except ValidationError:
return Response(
{"error": "Invalid email provided"}, status=status.HTTP_400_BAD_REQUEST
)
return Response({"error": "Invalid email provided"}, status=status.HTTP_400_BAD_REQUEST)
workspace = Workspace.objects.filter(slug=slug).first()
project = Project.objects.filter(pk=project_id).first()
@@ -186,14 +181,10 @@ class ProjectMemberSiloEndpoint(BaseAPIView):
if user:
# Check if user is part of the workspace
workspace_member = WorkspaceMember.objects.filter(
workspace=workspace, member=user
).first()
workspace_member = WorkspaceMember.objects.filter(workspace=workspace, member=user).first()
if workspace_member:
# Check if user is part of the project
project_member = ProjectMember.objects.filter(
project=project, member=user
).first()
project_member = ProjectMember.objects.filter(project=project, member=user).first()
if project_member:
return Response(
{"error": "User is already part of the workspace and project"},
@@ -238,7 +229,6 @@ class ProjectMemberSiloEndpoint(BaseAPIView):
return Response(user_data, status=status.HTTP_201_CREATED)
class ProjectMemberListCreateAPIEndpoint(BaseAPIView):
permission_classes = [ProjectMemberPermission]
use_read_replica = True
@@ -305,7 +295,6 @@ class ProjectMemberListCreateAPIEndpoint(BaseAPIView):
# API endpoint to get and update a project member
class ProjectMemberDetailAPIEndpoint(ProjectMemberListCreateAPIEndpoint):
@extend_schema(
operation_id="get_project_member",
summary="Get project member",
+53 -33
View File
@@ -22,7 +22,7 @@ from plane.utils.openapi import (
WORKSPACE_SLUG_PARAMETER,
TEAMSPACE_ID_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER
PER_PAGE_PARAMETER,
)
@@ -160,15 +160,20 @@ class TeamspaceViewSet(BaseViewSet):
operation_id="add_teamspace_projects",
summary="Add projects to a teamspace",
description="Add projects to a teamspace",
request=OpenApiRequest(request={"type": "object", "properties": {
"project_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid",
request=OpenApiRequest(
request={
"type": "object",
"properties": {
"project_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid",
},
},
},
},
}}),
}
),
parameters=[
WORKSPACE_SLUG_PARAMETER,
TEAMSPACE_ID_PARAMETER,
@@ -203,15 +208,20 @@ class TeamspaceViewSet(BaseViewSet):
operation_id="remove_teamspace_projects",
summary="Remove projects from a teamspace",
description="Remove projects from a teamspace by its ID",
request=OpenApiRequest(request={"type": "object", "properties": {
"project_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid",
request=OpenApiRequest(
request={
"type": "object",
"properties": {
"project_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid",
},
},
},
},
}}),
}
),
parameters=[
WORKSPACE_SLUG_PARAMETER,
TEAMSPACE_ID_PARAMETER,
@@ -256,15 +266,20 @@ class TeamspaceViewSet(BaseViewSet):
operation_id="add_teamspace_members",
summary="Add members to a teamspace",
description="Add members to a teamspace",
request=OpenApiRequest(request={"type": "object", "properties": {
"member_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid",
request=OpenApiRequest(
request={
"type": "object",
"properties": {
"member_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid",
},
},
},
},
}}),
}
),
parameters=[
WORKSPACE_SLUG_PARAMETER,
TEAMSPACE_ID_PARAMETER,
@@ -297,15 +312,20 @@ class TeamspaceViewSet(BaseViewSet):
operation_id="remove_teamspace_members",
summary="Delete members from a teamspace",
description="Delete members from a teamspace by its ID",
request=OpenApiRequest(request={"type": "object", "properties": {
"member_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid",
request=OpenApiRequest(
request={
"type": "object",
"properties": {
"member_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid",
},
},
},
},
}}),
}
),
parameters=[
WORKSPACE_SLUG_PARAMETER,
TEAMSPACE_ID_PARAMETER,
+1
View File
@@ -10,6 +10,7 @@ from plane.db.models import User
from plane.utils.openapi.decorators import user_docs
from plane.utils.openapi import USER_EXAMPLE
class UserEndpoint(BaseAPIView):
serializer_class = UserLiteSerializer
model = User
+1 -3
View File
@@ -37,9 +37,7 @@ def check_teamspace_membership(view, request: Request) -> bool:
).values_list("team_space_id", flat=True)
# return True if the user is a member of any of the teamspace
return TeamspaceMember.objects.filter(
member=request.user, team_space_id__in=teamspace_ids
).exists()
return TeamspaceMember.objects.filter(member=request.user, team_space_id__in=teamspace_ids).exists()
return False
+2 -24
View File
@@ -148,14 +148,13 @@ from .deploy_board import DeployBoardSerializer
# Extended serializers
from .extended.issue import ExtendedIssueCreateSerializer as IssueCreateSerializer # noqa: F811
from .extended.issue import ExtendedIssueCreateSerializer as IssueCreateSerializer # noqa: F811
__all__ = [
# Base serializers
"BaseSerializer",
"DynamicBaseSerializer",
# User serializers
"UserSerializer",
"UserLiteSerializer",
@@ -166,7 +165,6 @@ __all__ = [
"UserMeSettingsSerializer",
"ProfileSerializer",
"AccountSerializer",
# Workspace serializers
"WorkSpaceSerializer",
"WorkSpaceMemberSerializer",
@@ -181,7 +179,6 @@ __all__ = [
"WorkspaceHomePreferenceSerializer",
"StickySerializer",
"WorkspaceUserMeSerializer",
# Project serializers
"ProjectSerializer",
"ProjectListSerializer",
@@ -194,25 +191,20 @@ __all__ = [
"ProjectMemberAdminSerializer",
"ProjectPublicMemberSerializer",
"ProjectMemberRoleSerializer",
# State serializers
"StateSerializer",
"StateLiteSerializer",
# View serializers
"IssueViewSerializer",
"ViewIssueListSerializer",
# Cycle serializers
"CycleSerializer",
"CycleIssueSerializer",
"CycleWriteSerializer",
"CycleUserPropertiesSerializer",
"EntityProgressSerializer",
# Asset serializers
"FileAssetSerializer",
# Issue serializers
"IssueCreateSerializer",
"IssueActivitySerializer",
@@ -242,7 +234,6 @@ __all__ = [
"IssueListDetailSerializer",
"IssueDuplicateSerializer",
"IssueAttachmentSerializer",
# Module serializers
"ModuleDetailSerializer",
"ModuleWriteSerializer",
@@ -250,14 +241,11 @@ __all__ = [
"ModuleIssueSerializer",
"ModuleLinkSerializer",
"ModuleUserPropertiesSerializer",
# API serializers
"APITokenSerializer",
"APITokenReadSerializer",
# Importer serializers
"ImporterSerializer",
# Page serializers
"PageSerializer",
"PageLiteSerializer",
@@ -266,42 +254,33 @@ __all__ = [
"PageBinaryUpdateSerializer",
"PageVersionDetailSerializer",
"PageUserSerializer",
# Estimate serializers
"EstimateSerializer",
"EstimatePointSerializer",
"EstimateReadSerializer",
"WorkspaceEstimateSerializer",
# Intake serializers
"IntakeSerializer",
"IntakeIssueSerializer",
"IssueStateIntakeSerializer",
"IntakeIssueLiteSerializer",
"IntakeIssueDetailSerializer",
# Analytic serializers
"AnalyticViewSerializer",
# Notification serializers
"NotificationSerializer",
"UserNotificationPreferenceSerializer",
# Exporter serializers
"ExporterHistorySerializer",
# Webhook serializers
"WebhookSerializer",
"WebhookLogSerializer",
# Favorite serializers
"UserFavoriteSerializer",
# Draft serializers
"DraftIssueCreateSerializer",
"DraftIssueSerializer",
"DraftIssueDetailSerializer",
# Integration serializers
"IntegrationSerializer",
"WorkspaceIntegrationSerializer",
@@ -310,7 +289,6 @@ __all__ = [
"GithubRepositorySyncSerializer",
"GithubCommentSyncSerializer",
"SlackProjectSyncSerializer",
# Deploy board serializers
"DeployBoardSerializer",
]
]
@@ -70,7 +70,8 @@ class ExtendedIssueCreateSerializer(IssueCreateSerializer):
)
):
intake_responsibilities = IntakeResponsibility.objects.filter(
project_id=project_id, intake=intake_id,
project_id=project_id,
intake=intake_id,
type=IntakeResponsibilityTypeChoices.ASSIGNEE,
).values_list("user_id", flat=True)
try:
+2
View File
@@ -177,6 +177,8 @@ class PageLiteSerializer(BaseSerializer):
"is_shared",
"sort_order",
]
class PageVersionSerializer(BaseSerializer):
class Meta:
model = PageVersion
+4 -13
View File
@@ -17,12 +17,8 @@ class ViewIssueListSerializer(serializers.Serializer):
# Check if the user has access to the customer request count
if slug and user_id:
if check_workspace_feature_flag(
feature_key=FeatureFlag.CUSTOMERS, slug=slug, user_id=user_id
):
self.fields["customer_request_ids"] = serializers.ListField(
read_only=True
)
if check_workspace_feature_flag(feature_key=FeatureFlag.CUSTOMERS, slug=slug, user_id=user_id):
self.fields["customer_request_ids"] = serializers.ListField(read_only=True)
self.fields["customer_ids"] = serializers.ListField(read_only=True)
def get_assignee_ids(self, instance):
@@ -35,15 +31,10 @@ class ViewIssueListSerializer(serializers.Serializer):
return [module.module_id for module in instance.issue_module.all()]
def get_customer_ids(self, instance):
return [
customer.customer_id for customer in instance.customer_request_issues.all()
]
return [customer.customer_id for customer in instance.customer_request_issues.all()]
def get_customer_request_ids(self, instance):
return [
customer_request.customer_request_id
for customer_request in instance.customer_request_issues.all()
]
return [customer_request.customer_request_id for customer_request in instance.customer_request_issues.all()]
def to_representation(self, instance):
data = {
+1 -1
View File
@@ -8,4 +8,4 @@ urlpatterns = [
ExportIssuesEndpoint.as_view(),
name="export-issues",
),
]
]
+1 -3
View File
@@ -21,9 +21,7 @@ urlpatterns = [
),
path(
"integrations/<uuid:pk>/",
IntegrationViewSet.as_view(
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
),
IntegrationViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
name="integrations",
),
path(
+10 -38
View File
@@ -3,19 +3,7 @@ from rest_framework import status
from typing import Dict, List, Any
from django.db import models
from django.db.models import (
QuerySet,
Q,
Count,
Case,
When,
Value,
F,
CharField,
Max,
OuterRef,
Subquery
)
from django.db.models import QuerySet, Q, Count, Case, When, Value, F, CharField, Max, OuterRef, Subquery
from django.http import HttpRequest
from django.db.models.functions import TruncMonth, Cast, Concat, Coalesce
from django.utils import timezone
@@ -34,7 +22,7 @@ from plane.db.models import (
Workspace,
ProjectMember,
User,
Page
Page,
)
from plane.ee.models import EntityUpdates, ProjectAttribute
from plane.utils.build_chart import build_analytics_chart
@@ -395,34 +383,18 @@ class AdvanceAnalyticsStatsEndpoint(AdvanceAnalyticsBaseView):
projects_qs = (
Project.objects.filter(id__in=project_ids)
.annotate(
total_work_items=Coalesce(
Subquery(issue_subquery.values("total_work_items")[:1]), Value(0)
),
total_work_items=Coalesce(Subquery(issue_subquery.values("total_work_items")[:1]), Value(0)),
completed_work_items=Coalesce(
Subquery(issue_subquery.values("completed_work_items")[:1]),
Value(0),
),
total_epics=Coalesce(
Subquery(issue_subquery.values("total_epics")[:1]), Value(0)
),
total_intake=Coalesce(
Subquery(issue_subquery.values("total_intake")[:1]), Value(0)
),
total_cycles=Coalesce(
Subquery(cycle_subquery.values("total_cycles")[:1]), Value(0)
),
total_modules=Coalesce(
Subquery(module_subquery.values("total_modules")[:1]), Value(0)
),
total_members=Coalesce(
Subquery(member_subquery.values("total_members")[:1]), Value(0)
),
total_pages=Coalesce(
Subquery(page_subquery.values("total_pages")[:1]), Value(0)
),
total_views=Coalesce(
Subquery(view_subquery.values("total_views")[:1]), Value(0)
),
total_epics=Coalesce(Subquery(issue_subquery.values("total_epics")[:1]), Value(0)),
total_intake=Coalesce(Subquery(issue_subquery.values("total_intake")[:1]), Value(0)),
total_cycles=Coalesce(Subquery(cycle_subquery.values("total_cycles")[:1]), Value(0)),
total_modules=Coalesce(Subquery(module_subquery.values("total_modules")[:1]), Value(0)),
total_members=Coalesce(Subquery(member_subquery.values("total_members")[:1]), Value(0)),
total_pages=Coalesce(Subquery(page_subquery.values("total_pages")[:1]), Value(0)),
total_views=Coalesce(Subquery(view_subquery.values("total_views")[:1]), Value(0)),
)
.values(
"id",
+4 -12
View File
@@ -34,9 +34,7 @@ class ProxyUploadEndpoint(BaseAPIView):
# Decode the original S3 URL (we don't actually use it, just for validation)
_ = base64.urlsafe_b64decode(encoded_s3_url.encode()).decode()
except Exception:
return Response(
{"error": "Invalid S3 URL"}, status=status.HTTP_400_BAD_REQUEST
)
return Response({"error": "Invalid S3 URL"}, status=status.HTTP_400_BAD_REQUEST)
# Get the uploaded file and form data
try:
@@ -48,9 +46,7 @@ class ProxyUploadEndpoint(BaseAPIView):
# Validate required fields
if not all([object_key, content_type, policy]):
return Response(
{"error": "Invalid request"}, status=status.HTTP_400_BAD_REQUEST
)
return Response({"error": "Invalid request"}, status=status.HTTP_400_BAD_REQUEST)
# Upload directly to S3 using boto3
storage = S3Storage(is_server=True)
@@ -120,9 +116,7 @@ class ProxyDownloadEndpoint(BaseAPIView):
# Get object metadata first
metadata = storage.get_object_metadata(object_name)
if not metadata:
return Response(
{"error": "File not found"}, status=status.HTTP_404_NOT_FOUND
)
return Response({"error": "File not found"}, status=status.HTTP_404_NOT_FOUND)
# Stream the file from S3
def stream_from_s3():
@@ -150,9 +144,7 @@ class ProxyDownloadEndpoint(BaseAPIView):
disposition = params.get("disposition", "inline")
filename = params.get("filename")
if filename:
response["Content-Disposition"] = (
f'{disposition}; filename="{filename}"'
)
response["Content-Disposition"] = f'{disposition}; filename="{filename}"'
else:
response["Content-Disposition"] = disposition
+9 -8
View File
@@ -29,6 +29,7 @@ from plane.utils.core.mixins import ReadReplicaControlMixin
logger = logging.getLogger("plane.api")
class TimezoneMixin:
"""
This enables timezone conversion according
@@ -88,8 +89,8 @@ class BaseViewSet(TimezoneMixin, ReadReplicaControlMixin, ModelViewSet, BasePagi
extra={
"error_code": "VALIDATION_ERROR",
"error_message": str(e),
}
)
},
)
return Response(
{"error": "Please provide valid detail"},
status=status.HTTP_400_BAD_REQUEST,
@@ -101,7 +102,7 @@ class BaseViewSet(TimezoneMixin, ReadReplicaControlMixin, ModelViewSet, BasePagi
extra={
"error_code": "OBJECT_DOES_NOT_EXIST",
"error_message": str(e),
}
},
)
return Response(
{"error": "The required object does not exist."},
@@ -114,7 +115,7 @@ class BaseViewSet(TimezoneMixin, ReadReplicaControlMixin, ModelViewSet, BasePagi
extra={
"error_code": "KEY_ERROR",
"error_message": str(e),
}
},
)
return Response(
{"error": "The required key does not exist."},
@@ -205,8 +206,8 @@ class BaseAPIView(TimezoneMixin, ReadReplicaControlMixin, APIView, BasePaginator
extra={
"error_code": "VALIDATION_ERROR",
"error_message": str(e),
}
)
},
)
return Response(
{"error": "Please provide valid detail"},
status=status.HTTP_400_BAD_REQUEST,
@@ -218,7 +219,7 @@ class BaseAPIView(TimezoneMixin, ReadReplicaControlMixin, APIView, BasePaginator
extra={
"error_code": "OBJECT_DOES_NOT_EXIST",
"error_message": str(e),
}
},
)
return Response(
{"error": "The required object does not exist."},
@@ -231,7 +232,7 @@ class BaseAPIView(TimezoneMixin, ReadReplicaControlMixin, APIView, BasePaginator
extra={
"error_code": "KEY_ERROR",
"error_message": str(e),
}
},
)
return Response(
{"error": "The required key does not exist."},
+30 -99
View File
@@ -63,9 +63,6 @@ from plane.utils.timezone_converter import (
convert_to_utc,
user_timezone_converter,
)
from plane.ee.bgtasks.entity_issue_state_progress_task import (
entity_issue_state_activity_task,
)
from plane.ee.models import EntityProgress
@@ -105,9 +102,7 @@ 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(
@@ -158,8 +153,7 @@ 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")),
@@ -178,11 +172,7 @@ 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())),
)
@@ -214,9 +204,7 @@ 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
@@ -283,16 +271,10 @@ 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, version=2)
cycle = (
@@ -332,9 +314,7 @@ 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(
@@ -350,17 +330,13 @@ 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(
@@ -368,23 +344,17 @@ 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,
)
@@ -617,9 +587,7 @@ 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
@@ -641,11 +609,7 @@ 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",
@@ -696,9 +660,7 @@ 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,
@@ -804,12 +766,8 @@ 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
)
@@ -833,13 +791,9 @@ 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",
@@ -885,9 +839,7 @@ 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:
@@ -947,22 +899,11 @@ 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,
@@ -980,9 +921,7 @@ 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",
@@ -1068,9 +1007,7 @@ 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()),
@@ -1105,9 +1042,7 @@ 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()),
@@ -1209,11 +1144,7 @@ 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",
+5 -9
View File
@@ -239,18 +239,14 @@ class CycleIssueViewSet(BaseViewSet):
existing_issues = [str(cycle_issue.issue_id) for cycle_issue in cycle_issues]
new_issues = list(set(issues) - set(existing_issues))
issue_cycle_data_added = [
{"issue_id": str(issue_id), "cycle_id": str(cycle_id)}
for issue_id in issues
]
issue_cycle_data_added = [{"issue_id": str(issue_id), "cycle_id": str(cycle_id)} for issue_id in issues]
issues_removed = CycleIssue.objects.filter(
issue_id__in=existing_issues, workspace__slug=slug
).values("issue_id", "cycle_id")
issues_removed = CycleIssue.objects.filter(issue_id__in=existing_issues, workspace__slug=slug).values(
"issue_id", "cycle_id"
)
issue_cycle_data_removed = [
{"issue_id": str(issue["issue_id"]), "cycle_id": str(issue["cycle_id"])}
for issue in issues_removed
{"issue_id": str(issue["issue_id"]), "cycle_id": str(issue["cycle_id"])} for issue in issues_removed
]
# New issues to create
@@ -2,4 +2,4 @@ from .base import ExportIssuesEndpoint
__all__ = [
"ExportIssuesEndpoint",
]
]
+28 -81
View File
@@ -57,9 +57,7 @@ class ServiceIssueImportSummaryEndpoint(BaseAPIView):
integration__provider="github", workspace__slug=slug
)
access_tokens_url = workspace_integration.metadata.get(
"access_tokens_url", False
)
access_tokens_url = workspace_integration.metadata.get("access_tokens_url", False)
if not access_tokens_url:
return Response(
@@ -69,9 +67,7 @@ class ServiceIssueImportSummaryEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
issue_count, labels, collaborators = get_github_repo_details(
access_tokens_url, owner, repo
)
issue_count, labels, collaborators = get_github_repo_details(access_tokens_url, owner, repo)
return Response(
{
"issue_count": issue_count,
@@ -92,25 +88,19 @@ class ServiceIssueImportSummaryEndpoint(BaseAPIView):
for key, error_message in params.items():
if not request.GET.get(key, False):
return Response(
{"error": error_message}, status=status.HTTP_400_BAD_REQUEST
)
return Response({"error": error_message}, status=status.HTTP_400_BAD_REQUEST)
project_key = request.GET.get("project_key", "")
api_token = request.GET.get("api_token", "")
email = request.GET.get("email", "")
cloud_hostname = request.GET.get("cloud_hostname", "")
response = jira_project_issue_summary(
email, api_token, project_key, cloud_hostname
)
response = jira_project_issue_summary(email, api_token, project_key, cloud_hostname)
if "error" in response:
return Response(response, status=status.HTTP_400_BAD_REQUEST)
else:
return Response(response, status=status.HTTP_200_OK)
return Response(
{"error": "Service not supported yet"}, status=status.HTTP_400_BAD_REQUEST
)
return Response({"error": "Service not supported yet"}, status=status.HTTP_400_BAD_REQUEST)
class ImportServiceEndpoint(BaseAPIView):
@@ -120,9 +110,7 @@ class ImportServiceEndpoint(BaseAPIView):
project_id = request.data.get("project_id", False)
if not project_id:
return Response(
{"error": "Project ID is required"}, status=status.HTTP_400_BAD_REQUEST
)
return Response({"error": "Project ID is required"}, status=status.HTTP_400_BAD_REQUEST)
workspace = Workspace.objects.get(slug=slug)
@@ -136,13 +124,9 @@ class ImportServiceEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
api_token = APIToken.objects.filter(
user=request.user, workspace=workspace
).first()
api_token = APIToken.objects.filter(user=request.user, workspace=workspace).first()
if api_token is None:
api_token = APIToken.objects.create(
user=request.user, label="Importer", workspace=workspace
)
api_token = APIToken.objects.create(user=request.user, label="Importer", workspace=workspace)
importer = Importer.objects.create(
service=service,
@@ -185,13 +169,9 @@ class ImportServiceEndpoint(BaseAPIView):
{"error": "Data, config and metadata are required"},
status=status.HTTP_400_BAD_REQUEST,
)
api_token = APIToken.objects.filter(
user=request.user, workspace=workspace
).first()
api_token = APIToken.objects.filter(user=request.user, workspace=workspace).first()
if api_token is None:
api_token = APIToken.objects.create(
user=request.user, label="Importer", workspace=workspace
)
api_token = APIToken.objects.create(user=request.user, label="Importer", workspace=workspace)
importer = Importer.objects.create(
service=service,
@@ -210,9 +190,7 @@ class ImportServiceEndpoint(BaseAPIView):
serializer = ImporterSerializer(importer)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(
{"error": "Servivce not supported yet"}, status=status.HTTP_400_BAD_REQUEST
)
return Response({"error": "Servivce not supported yet"}, status=status.HTTP_400_BAD_REQUEST)
def get(self, request, slug):
imports = (
@@ -252,9 +230,7 @@ class ImportServiceEndpoint(BaseAPIView):
class UpdateServiceImportStatusEndpoint(BaseAPIView):
def post(self, request, slug, project_id, service, importer_id):
importer = Importer.objects.get(
pk=importer_id, workspace__slug=slug, project_id=project_id, service=service
)
importer = Importer.objects.get(pk=importer_id, workspace__slug=slug, project_id=project_id, service=service)
importer.status = request.data.get("status", "processing")
importer.save()
return Response(status.HTTP_200_OK)
@@ -266,38 +242,28 @@ class BulkImportIssuesEndpoint(BaseAPIView):
project = Project.objects.get(pk=project_id, workspace__slug=slug)
# Get the default state
default_state = State.objects.filter(
~Q(name="Triage"), project_id=project_id, default=True
).first()
default_state = State.objects.filter(~Q(name="Triage"), project_id=project_id, default=True).first()
# if there is no default state assign any random state
if default_state is None:
default_state = State.objects.filter(
~Q(name="Triage"), project_id=project_id
).first()
default_state = State.objects.filter(~Q(name="Triage"), project_id=project_id).first()
# Get the maximum sequence_id
last_id = IssueSequence.objects.filter(project_id=project_id).aggregate(
largest=Max("sequence")
)["largest"]
last_id = IssueSequence.objects.filter(project_id=project_id).aggregate(largest=Max("sequence"))["largest"]
last_id = 1 if last_id is None else last_id + 1
# Get the maximum sort order
largest_sort_order = Issue.objects.filter(
project_id=project_id, state=default_state
).aggregate(largest=Max("sort_order"))["largest"]
largest_sort_order = Issue.objects.filter(project_id=project_id, state=default_state).aggregate(
largest=Max("sort_order")
)["largest"]
largest_sort_order = (
65535 if largest_sort_order is None else largest_sort_order + 10000
)
largest_sort_order = 65535 if largest_sort_order is None else largest_sort_order + 10000
# Get the issues_data
issues_data = request.data.get("issues_data", [])
if not len(issues_data):
return Response(
{"error": "Issue data is required"}, status=status.HTTP_400_BAD_REQUEST
)
return Response({"error": "Issue data is required"}, status=status.HTTP_400_BAD_REQUEST)
# Issues
bulk_issues = []
@@ -306,19 +272,12 @@ class BulkImportIssuesEndpoint(BaseAPIView):
Issue(
project_id=project_id,
workspace_id=project.workspace_id,
state_id=(
issue_data.get("state")
if issue_data.get("state", False)
else default_state.id
),
state_id=(issue_data.get("state") if issue_data.get("state", False) else default_state.id),
name=issue_data.get("name", "Issue Created through Bulk"),
description_html=issue_data.get("description_html", "<p></p>"),
description_stripped=(
None
if (
issue_data.get("description_html") == ""
or issue_data.get("description_html") is None
)
if (issue_data.get("description_html") == "" or issue_data.get("description_html") is None)
else strip_tags(issue_data.get("description_html"))
),
sequence_id=last_id,
@@ -333,9 +292,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
largest_sort_order = largest_sort_order + 10000
last_id = last_id + 1
issues = Issue.objects.bulk_create(
bulk_issues, batch_size=100, ignore_conflicts=True
)
issues = Issue.objects.bulk_create(bulk_issues, batch_size=100, ignore_conflicts=True)
# Sequences
_ = IssueSequence.objects.bulk_create(
@@ -366,9 +323,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
for label_id in labels_list
]
_ = IssueLabel.objects.bulk_create(
bulk_issue_labels, batch_size=100, ignore_conflicts=True
)
_ = IssueLabel.objects.bulk_create(bulk_issue_labels, batch_size=100, ignore_conflicts=True)
# Attach Assignees
bulk_issue_assignees = []
@@ -385,9 +340,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
for assignee_id in assignees_list
]
_ = IssueAssignee.objects.bulk_create(
bulk_issue_assignees, batch_size=100, ignore_conflicts=True
)
_ = IssueAssignee.objects.bulk_create(bulk_issue_assignees, batch_size=100, ignore_conflicts=True)
# Track the issue activities
IssueActivity.objects.bulk_create(
@@ -475,9 +428,7 @@ class BulkImportModulesEndpoint(BaseAPIView):
ModuleLink(
module=module,
url=module_data.get("link", {}).get("url", "https://plane.so"),
title=module_data.get("link", {}).get(
"title", "Original Issue"
),
title=module_data.get("link", {}).get("title", "Original Issue"),
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
@@ -502,14 +453,10 @@ class BulkImportModulesEndpoint(BaseAPIView):
for issue in module_issues_list
]
_ = ModuleIssue.objects.bulk_create(
bulk_module_issues, batch_size=100, ignore_conflicts=True
)
_ = ModuleIssue.objects.bulk_create(bulk_module_issues, batch_size=100, ignore_conflicts=True)
serializer = ModuleSerializer(modules, many=True)
return Response(
{"modules": serializer.data}, status=status.HTTP_201_CREATED
)
return Response({"modules": serializer.data}, status=status.HTTP_201_CREATED)
else:
return Response(
-3
View File
@@ -53,9 +53,6 @@ from plane.ee.utils.workflow import WorkflowStateManager
from plane.ee.utils.check_user_teamspace_member import (
check_if_current_user_is_teamspace_member,
)
from plane.payment.flags.flag_decorator import check_workspace_feature_flag
from plane.ee.models import IntakeResponsibility
from plane.payment.flags.flag import FeatureFlag
class IntakeViewSet(BaseViewSet):
+5 -18
View File
@@ -72,12 +72,7 @@ class WorkspaceIntegrationViewSet(BaseViewSet):
permission_classes = [WorkSpaceAdminPermission]
def get_queryset(self):
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("integration")
)
return super().get_queryset().filter(workspace__slug=self.kwargs.get("slug")).select_related("integration")
def create(self, request, slug, provider):
workspace = Workspace.objects.get(slug=slug)
@@ -97,9 +92,7 @@ class WorkspaceIntegrationViewSet(BaseViewSet):
code = request.data.get("code", False)
if not code:
return Response(
{"error": "Code is required"}, status=status.HTTP_400_BAD_REQUEST
)
return Response({"error": "Code is required"}, status=status.HTTP_400_BAD_REQUEST)
slack_response = slack_oauth(code=code)
@@ -121,9 +114,7 @@ class WorkspaceIntegrationViewSet(BaseViewSet):
is_password_autoset=True,
is_bot=True,
first_name=integration.title,
avatar=(
integration.avatar_url if integration.avatar_url is not None else ""
),
avatar=(integration.avatar_url if integration.avatar_url is not None else ""),
)
# Create an API Token for the bot user
@@ -143,18 +134,14 @@ class WorkspaceIntegrationViewSet(BaseViewSet):
)
# Add bot user as a member of workspace
_ = WorkspaceMember.objects.create(
workspace=workspace_integration.workspace, member=bot_user, role=20
)
_ = WorkspaceMember.objects.create(workspace=workspace_integration.workspace, member=bot_user, role=20)
return Response(
WorkspaceIntegrationSerializer(workspace_integration).data,
status=status.HTTP_201_CREATED,
)
def destroy(self, request, slug, pk):
workspace_integration = WorkspaceIntegration.objects.get(
pk=pk, workspace__slug=slug
)
workspace_integration = WorkspaceIntegration.objects.get(pk=pk, workspace__slug=slug)
if workspace_integration.integration.provider == "github":
installation_id = workspace_integration.config.get("installation_id", False)
+6 -19
View File
@@ -28,9 +28,7 @@ class GithubRepositoriesEndpoint(BaseAPIView):
def get(self, request, slug, workspace_integration_id):
page = request.GET.get("page", 1)
workspace_integration = WorkspaceIntegration.objects.get(
workspace__slug=slug, pk=workspace_integration_id
)
workspace_integration = WorkspaceIntegration.objects.get(workspace__slug=slug, pk=workspace_integration_id)
if workspace_integration.integration.provider != "github":
return Response(
@@ -39,10 +37,7 @@ class GithubRepositoriesEndpoint(BaseAPIView):
)
access_tokens_url = workspace_integration.metadata["access_tokens_url"]
repositories_url = (
workspace_integration.metadata["repositories_url"]
+ f"?per_page=100&page={page}"
)
repositories_url = workspace_integration.metadata["repositories_url"] + f"?per_page=100&page={page}"
repositories = get_github_repos(access_tokens_url, repositories_url)
return Response(repositories, status=status.HTTP_200_OK)
@@ -78,17 +73,11 @@ class GithubRepositorySyncViewSet(BaseViewSet):
)
# Get the workspace integration
workspace_integration = WorkspaceIntegration.objects.get(
pk=workspace_integration_id
)
workspace_integration = WorkspaceIntegration.objects.get(pk=workspace_integration_id)
# Delete the old repository object
GithubRepositorySync.objects.filter(
project_id=project_id, workspace__slug=slug
).delete()
GithubRepository.objects.filter(
project_id=project_id, workspace__slug=slug
).delete()
GithubRepositorySync.objects.filter(project_id=project_id, workspace__slug=slug).delete()
GithubRepository.objects.filter(project_id=project_id, workspace__slug=slug).delete()
# Create repository
repo = GithubRepository.objects.create(
@@ -122,9 +111,7 @@ class GithubRepositorySyncViewSet(BaseViewSet):
)
# Add bot as a member in the project
_ = ProjectMember.objects.get_or_create(
member=workspace_integration.actor, role=20, project_id=project_id
)
_ = ProjectMember.objects.get_or_create(member=workspace_integration.actor, role=20, project_id=project_id)
# Return Response
return Response(
+4 -12
View File
@@ -37,19 +37,13 @@ class SlackProjectSyncViewSet(BaseViewSet):
code = request.data.get("code", False)
if not code:
return Response(
{"error": "Code is required"}, status=status.HTTP_400_BAD_REQUEST
)
return Response({"error": "Code is required"}, status=status.HTTP_400_BAD_REQUEST)
slack_response = slack_oauth(code=code)
workspace_integration = WorkspaceIntegration.objects.get(
workspace__slug=slug, pk=workspace_integration_id
)
workspace_integration = WorkspaceIntegration.objects.get(workspace__slug=slug, pk=workspace_integration_id)
workspace_integration = WorkspaceIntegration.objects.get(
pk=workspace_integration_id, workspace__slug=slug
)
workspace_integration = WorkspaceIntegration.objects.get(pk=workspace_integration_id, workspace__slug=slug)
slack_project_sync = SlackProjectSync.objects.create(
access_token=slack_response.get("access_token"),
scopes=slack_response.get("scope"),
@@ -61,9 +55,7 @@ class SlackProjectSyncViewSet(BaseViewSet):
workspace_integration=workspace_integration,
project_id=project_id,
)
_ = ProjectMember.objects.get_or_create(
member=workspace_integration.actor, role=20, project_id=project_id
)
_ = ProjectMember.objects.get_or_create(member=workspace_integration.actor, role=20, project_id=project_id)
serializer = SlackProjectSyncSerializer(slack_project_sync)
return Response(serializer.data, status=status.HTTP_200_OK)
except IntegrityError as e:
+3 -9
View File
@@ -43,16 +43,12 @@ class LabelViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN])
def create(self, request, slug, project_id):
try:
serializer = LabelSerializer(
data=request.data, context={"project_id": project_id}
)
serializer = LabelSerializer(data=request.data, context={"project_id": project_id})
if serializer.is_valid():
serializer.save(project_id=project_id)
project_activity.delay(
type="project.activity.updated",
requested_data=json.dumps(
{"label": serializer.data.get("id")}, cls=DjangoJSONEncoder
),
requested_data=json.dumps({"label": serializer.data.get("id")}, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
project_id=str(project_id),
current_instance=json.dumps({"label": None}, cls=DjangoJSONEncoder),
@@ -104,9 +100,7 @@ class LabelViewSet(BaseViewSet):
requested_data=json.dumps({"label": None}, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
project_id=str(project_id),
current_instance=json.dumps(
{"label": pk, "label_name": label.name}, cls=DjangoJSONEncoder
),
current_instance=json.dumps({"label": pk, "label_name": label.name}, cls=DjangoJSONEncoder),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
+1 -3
View File
@@ -63,9 +63,7 @@ class IssueSubscriberViewSet(BaseViewSet):
def subscribe(self, request, slug, project_id, issue_id):
if (
IssueSubscriber.objects.filter(
Q(issue__type__is_epic=False) | Q(issue__type__isnull=True)
)
IssueSubscriber.objects.filter(Q(issue__type__is_epic=False) | Q(issue__type__isnull=True))
.filter(
issue_id=issue_id,
subscriber=request.user,
+1 -3
View File
@@ -97,9 +97,7 @@ class WorkItemDescriptionVersionEndpoint(BaseAPIView):
).exists()
and not project.guest_view_all_features
and not issue.created_by == request.user
and not check_if_current_user_is_teamspace_member(
request.user.id, slug, project_id
)
and not check_if_current_user_is_teamspace_member(request.user.id, slug, project_id)
):
return Response(
{"error": "You are not allowed to view this issue"},
+12 -16
View File
@@ -512,14 +512,12 @@ class PagesDescriptionViewSet(BaseViewSet):
permission_classes = [ProjectPagePermission]
def retrieve(self, request, slug, project_id, page_id):
page = (
Page.objects.get(
Q(owned_by=self.request.user) | Q(access=0),
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
page = Page.objects.get(
Q(owned_by=self.request.user) | Q(access=0),
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
binary_data = page.description_binary
@@ -534,14 +532,12 @@ class PagesDescriptionViewSet(BaseViewSet):
return response
def partial_update(self, request, slug, project_id, page_id):
page = (
Page.objects.get(
Q(owned_by=self.request.user) | Q(access=0),
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
page = Page.objects.get(
Q(owned_by=self.request.user) | Q(access=0),
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
if page.is_locked:
+1 -6
View File
@@ -3,18 +3,15 @@ import json
from typing import Dict, Any, List
import uuid
import boto3
# Django imports
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
from django.db import IntegrityError
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
from django.db.models import Exists, F, OuterRef, Prefetch, Subquery
from django.utils import timezone
# Third Party imports
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
# Module imports
@@ -28,7 +25,6 @@ from plane.app.views.base import BaseAPIView, BaseViewSet
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.bgtasks.webhook_task import model_activity, webhook_activity
from plane.db.models import (
UserFavorite,
DeployBoard,
Intake,
IssueUserProperty,
@@ -43,7 +39,6 @@ from plane.db.models import (
WorkspaceMember,
APIToken,
)
from plane.utils.cache import cache_response
from plane.utils.host import base_host
# EE imports
@@ -26,7 +26,6 @@ from plane.app.serializers import (
from plane.app.permissions import WorkspaceUserPermission
from plane.db.models import Project, ProjectMember, IssueUserProperty, WorkspaceMember
from plane.db.models.user import BotTypeEnum
from plane.db.models.project import get_default_preferences
from plane.ee.models import TeamspaceMember, TeamspaceProject, PageUser
from plane.bgtasks.project_add_user_email_task import project_add_user_email
from plane.utils.host import base_host
+3 -9
View File
@@ -158,9 +158,7 @@ class IssueSearchEndpoint(BaseAPIView):
# Filter issues and epics by project
if workspace_search == "false":
issues = self.filter_issues_by_project(project_id, issues)
issues_and_epics = self.filter_issues_by_project(
project_id, issues_and_epics
)
issues_and_epics = self.filter_issues_by_project(project_id, issues_and_epics)
# Filter issues and epics by query
if epic == "true":
@@ -180,9 +178,7 @@ class IssueSearchEndpoint(BaseAPIView):
issues = self.search_issues_and_excluding_parent(issues_and_epics, issue_id)
if issue_relation == "true" and issue_id:
issues = self.filter_issues_excluding_related_issues(
issue_id, issues_and_epics
)
issues = self.filter_issues_excluding_related_issues(issue_id, issues_and_epics)
if sub_issue == "true" and issue_id:
issues = self.filter_root_issues_only(issue_id, issues)
@@ -197,9 +193,7 @@ class IssueSearchEndpoint(BaseAPIView):
issues = self.filter_issues_without_target_date(issues)
if convert == "true" and issue_id:
issues = self.filter_issues_and_epics_by_excluding_given_issue_id(
query, issue_id, issues_and_epics
)
issues = self.filter_issues_and_epics_by_excluding_given_issue_id(query, issue_id, issues_and_epics)
if ProjectMember.objects.filter(
project_id=project_id, member=self.request.user, is_active=True, role=5
).exists():
+1 -3
View File
@@ -39,9 +39,7 @@ class WorkspaceSearchEndpoint(BaseAPIView):
for field in fields:
q |= Q(**{f"{field}__icontains": query})
return (
Page.objects.filter(
q, workspace__slug=slug, archived_at__isnull=True, is_global=True
)
Page.objects.filter(q, workspace__slug=slug, archived_at__isnull=True, is_global=True)
.filter(Q(owned_by=self.request.user) | Q(access=0))
.distinct()
.values("name", "id", "workspace__slug")
+6 -12
View File
@@ -263,9 +263,9 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
)
def get_queryset(self):
return Issue.issue_objects.filter(
workspace__slug=self.kwargs.get("slug")
).accessible_to(self.request.user.id, self.kwargs.get("slug"))
return Issue.issue_objects.filter(workspace__slug=self.kwargs.get("slug")).accessible_to(
self.request.user.id, self.kwargs.get("slug")
)
@method_decorator(gzip_page)
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
@@ -285,9 +285,7 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
issue_queryset = issue_queryset.filter(**filters)
if request.query_params.get("sub_issue", None) == "false":
issue_queryset = issue_queryset.filter(
Q(parent__isnull=True) | (Q(parent__type__is_epic=True))
)
issue_queryset = issue_queryset.filter(Q(parent__isnull=True) | (Q(parent__type__is_epic=True)))
# Get common project permission filters
permission_filters = self._get_project_permission_filters()
@@ -385,9 +383,7 @@ class IssueViewViewSet(BaseViewSet):
is_active=True,
).exists()
and not project.guest_view_all_features
and not check_if_current_user_is_teamspace_member(
request.user.id, slug, project_id
)
and not check_if_current_user_is_teamspace_member(request.user.id, slug, project_id)
):
queryset = queryset.filter(owned_by=request.user)
fields = [field for field in request.GET.get("fields", "").split(",") if field]
@@ -413,9 +409,7 @@ class IssueViewViewSet(BaseViewSet):
).exists()
and not project.guest_view_all_features
and not issue_view.owned_by == request.user
and not check_if_current_user_is_teamspace_member(
request.user.id, slug, project_id
)
and not check_if_current_user_is_teamspace_member(request.user.id, slug, project_id)
):
return Response(
{"error": "You are not allowed to view this issue"},
+4 -12
View File
@@ -123,9 +123,7 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
# EE start
if request.data.get("state_id"):
workflow_state_manager = WorkflowStateManager(
project_id=request.data.get("project_id", None), slug=slug
)
workflow_state_manager = WorkflowStateManager(project_id=request.data.get("project_id", None), slug=slug)
if workflow_state_manager.validate_issue_creation(
state_id=request.data.get("state_id"),
user_id=request.user.id,
@@ -186,12 +184,8 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
# EE start
# Check if state is updated then is the transition allowed
workflow_state_manager = WorkflowStateManager(
project_id=issue.project_id, slug=slug
)
if request.data.get(
"state_id"
) and not workflow_state_manager.validate_state_transition(
workflow_state_manager = WorkflowStateManager(project_id=issue.project_id, slug=slug)
if request.data.get("state_id") and not workflow_state_manager.validate_state_transition(
issue=issue,
new_state_id=request.data.get("state_id"),
user_id=request.user.id,
@@ -351,9 +345,7 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
draft_issue_id=None,
)
draft_issue_property_values = DraftIssuePropertyValue.objects.filter(
draft_issue=draft_issue
)
draft_issue_property_values = DraftIssuePropertyValue.objects.filter(draft_issue=draft_issue)
IssuePropertyValue.objects.bulk_create(
[
IssuePropertyValue(
@@ -20,8 +20,7 @@ class WorkspaceFavoriteEndpoint(BaseAPIView):
def get(self, request, slug):
# the second filter is to check if the user is a member of the project
favorites = UserFavorite.objects.filter(user=request.user, workspace__slug=slug, parent__isnull=True).filter(
Q(project__isnull=True) & ~Q(entity_type="page")
| (Q(project__isnull=False))
Q(project__isnull=True) & ~Q(entity_type="page") | (Q(project__isnull=False))
)
serializer = UserFavoriteSerializer(favorites, many=True)
@@ -90,9 +90,9 @@ class Adapter:
"""Check if sign up is enabled or not and raise exception if not enabled"""
# Get configuration value
(ENABLE_SIGNUP,) = get_configuration_value([
{"key": "ENABLE_SIGNUP", "default": os.environ.get("ENABLE_SIGNUP", "1")}
])
(ENABLE_SIGNUP,) = get_configuration_value(
[{"key": "ENABLE_SIGNUP", "default": os.environ.get("ENABLE_SIGNUP", "1")}]
)
# Check if sign up is disabled and invite is present or not
if ENABLE_SIGNUP == "0" and not WorkspaceMemberInvite.objects.filter(email=email).exists():
@@ -1,6 +1,7 @@
from celery import shared_task
import logging
@shared_task
def app_delete_updates(application_id: str):
"""
@@ -17,4 +18,4 @@ def app_delete_updates(application_id: str):
)
for app_installation in app_installations:
app_installation.delete()
return
return
@@ -1,5 +1,6 @@
from celery import shared_task
@shared_task
def app_logo_asset_updates(application_id: str):
from plane.authentication.models import Application, WorkspaceAppInstallation
@@ -11,9 +11,7 @@ def app_webhook_url_updates(application_id: str):
application = Application.objects.get(id=application_id)
if not application:
return
app_installations = WorkspaceAppInstallation.objects.filter(
application_id=application_id
)
app_installations = WorkspaceAppInstallation.objects.filter(application_id=application_id)
if not application.webhook_url:
# Delete webhooks for all app installations
for app_installation in app_installations:
@@ -11,7 +11,9 @@ def send_app_uninstall_webhook(webhook_url: str, workspace_id: str, application_
"""
try:
logging.getLogger("plane.worker").info(f"Sending app uninstall webhook for workspace {workspace_id} and application {application_id} and app installation {app_installation_id}")
logging.getLogger("plane.worker").info(
f"Sending app uninstall webhook for workspace {workspace_id} and application {application_id} and app installation {app_installation_id}"
)
payload = {
"workspace_id": str(workspace_id),
"application_id": str(application_id),
@@ -19,9 +19,7 @@ class OauthApplicationWorkspacePermission(BasePermission):
oauth2authenticated = False
if is_authenticated:
oauth2authenticated = isinstance(
request.successful_authenticator, OAuth2Authentication
)
oauth2authenticated = isinstance(request.successful_authenticator, OAuth2Authentication)
# If not OAuth2 authenticated, allow only if user is authenticated
if not oauth2authenticated:
@@ -101,9 +101,7 @@ class GiteaOAuthProvider(OauthAdapter):
else None
),
"refresh_token_expired_at": (
datetime.fromtimestamp(
token_response.get("refresh_token_expired_at"), tz=pytz.utc
)
datetime.fromtimestamp(token_response.get("refresh_token_expired_at"), tz=pytz.utc)
if token_response.get("refresh_token_expired_at")
else None
),
@@ -168,4 +166,4 @@ class GiteaOAuthProvider(OauthAdapter):
"is_password_autoset": True,
},
}
)
)
@@ -128,10 +128,11 @@ class GitHubOAuthProvider(OauthAdapter):
return email
except requests.RequestException:
logger.warning(
"Error getting email from Github", extra={
"Error getting email from Github",
extra={
"error_code": AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"],
"error_message": "GITHUB_OAUTH_PROVIDER_ERROR",
}
},
)
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"],
@@ -53,19 +53,9 @@ class GoogleOAuthProvider(OauthAdapter):
client_id = GOOGLE_CLIENT_ID
client_secret = GOOGLE_CLIENT_SECRET
scheme = (
"https"
if settings.IS_HEROKU
else "https"
if request.is_secure()
else "http"
)
scheme = "https" if settings.IS_HEROKU else "https" if request.is_secure() else "http"
redirect_uri = (
redirect_uri
if redirect_uri
else (f"""{scheme}://{request.get_host()}/auth/google/callback/""")
)
redirect_uri = redirect_uri if redirect_uri else (f"""{scheme}://{request.get_host()}/auth/google/callback/""")
url_params = {
"client_id": client_id,
@@ -25,15 +25,14 @@ class ApplicationLinksSerializer(serializers.Serializer):
"url": instance.get("url", ""),
}
class ApplicationSerializer(BaseSerializer):
is_owned = serializers.BooleanField(read_only=True)
is_installed = serializers.BooleanField(read_only=True)
installation_id = serializers.UUIDField(read_only=True, required=False)
logo_url = serializers.CharField(read_only=True)
attachments_urls = serializers.SerializerMethodField()
attachments = serializers.PrimaryKeyRelatedField(
queryset=FileAsset.objects.all(), many=True, required=False
)
attachments = serializers.PrimaryKeyRelatedField(queryset=FileAsset.objects.all(), many=True, required=False)
categories = serializers.PrimaryKeyRelatedField(
queryset=ApplicationCategory.objects.all(), many=True, required=False
)
@@ -74,10 +74,7 @@ def process_workspace_project_invitations(user):
)
# Sync workspace members
[
member_sync_task.delay(project_member_invite.workspace.slug)
for project_member_invite in project_member_invites
]
[member_sync_task.delay(project_member_invite.workspace.slug) for project_member_invite in project_member_invites]
# Delete all the invites
workspace_member_invites.delete()
@@ -37,9 +37,7 @@ class GiteaOauthInitiateEndpoint(View):
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
url = urljoin(base_host(request=request, is_app=True), "?" + urlencode(params))
return HttpResponseRedirect(url)
try:
state = uuid.uuid4().hex
@@ -51,9 +49,7 @@ class GiteaOauthInitiateEndpoint(View):
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
url = urljoin(base_host(request=request, is_app=True), "?" + urlencode(params))
return HttpResponseRedirect(url)
@@ -87,9 +83,7 @@ class GiteaCallbackEndpoint(View):
return HttpResponseRedirect(url)
try:
provider = GiteaOAuthProvider(
request=request, code=code, callback=post_user_auth_workflow
)
provider = GiteaOAuthProvider(request=request, code=code, callback=post_user_auth_workflow)
user = provider.authenticate()
# Login the user and record his device info
user_login(request=request, user=user, is_app=True)
@@ -21,6 +21,7 @@ from plane.utils.path_validator import get_safe_redirect_url
logger = logging.getLogger("plane.authentication")
class GitHubOauthInitiateEndpoint(View):
def get(self, request):
# Get host and next path
@@ -62,10 +63,13 @@ class GitHubCallbackEndpoint(View):
next_path = request.session.get("next_path")
if state != request.session.get("state", ""):
logger.warning("State mismatch in Github callback", extra={
"error_code": AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"],
"error_message": "GITHUB_OAUTH_PROVIDER_ERROR",
})
logger.warning(
"State mismatch in Github callback",
extra={
"error_code": AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"],
"error_message": "GITHUB_OAUTH_PROVIDER_ERROR",
},
)
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"],
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
@@ -77,10 +81,13 @@ class GitHubCallbackEndpoint(View):
return HttpResponseRedirect(url)
if not code:
logger.warning("Code not found in Github callback", extra={
"error_code": AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"],
"error_message": "GITHUB_OAUTH_PROVIDER_ERROR",
})
logger.warning(
"Code not found in Github callback",
extra={
"error_code": AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"],
"error_message": "GITHUB_OAUTH_PROVIDER_ERROR",
},
)
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"],
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
@@ -63,10 +63,13 @@ class GitLabCallbackEndpoint(View):
next_path = request.session.get("next_path")
if state != request.session.get("state", ""):
logger.warning("State mismatch in GitLab callback", extra={
"error_code": AUTHENTICATION_ERROR_CODES["GITLAB_OAUTH_PROVIDER_ERROR"],
"error_message": "GITLAB_OAUTH_PROVIDER_ERROR",
})
logger.warning(
"State mismatch in GitLab callback",
extra={
"error_code": AUTHENTICATION_ERROR_CODES["GITLAB_OAUTH_PROVIDER_ERROR"],
"error_message": "GITLAB_OAUTH_PROVIDER_ERROR",
},
)
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITLAB_OAUTH_PROVIDER_ERROR"],
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
@@ -78,10 +81,13 @@ class GitLabCallbackEndpoint(View):
return HttpResponseRedirect(url)
if not code:
logger.warning("Code not found in GitLab callback", extra={
"error_code": AUTHENTICATION_ERROR_CODES["GITLAB_OAUTH_PROVIDER_ERROR"],
"error_message": "GITLAB_OAUTH_PROVIDER_ERROR",
})
logger.warning(
"Code not found in GitLab callback",
extra={
"error_code": AUTHENTICATION_ERROR_CODES["GITLAB_OAUTH_PROVIDER_ERROR"],
"error_message": "GITLAB_OAUTH_PROVIDER_ERROR",
},
)
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITLAB_OAUTH_PROVIDER_ERROR"],
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
@@ -63,10 +63,13 @@ class GoogleCallbackEndpoint(View):
next_path = request.session.get("next_path")
if state != request.session.get("state", ""):
logger.warning("State mismatch in Google callback", extra={
"error_code": AUTHENTICATION_ERROR_CODES["GOOGLE_OAUTH_PROVIDER_ERROR"],
"error_message": "GOOGLE_OAUTH_PROVIDER_ERROR",
})
logger.warning(
"State mismatch in Google callback",
extra={
"error_code": AUTHENTICATION_ERROR_CODES["GOOGLE_OAUTH_PROVIDER_ERROR"],
"error_message": "GOOGLE_OAUTH_PROVIDER_ERROR",
},
)
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GOOGLE_OAUTH_PROVIDER_ERROR"],
error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
@@ -77,10 +80,13 @@ class GoogleCallbackEndpoint(View):
)
return HttpResponseRedirect(url)
if not code:
logger.warning("Code not found in Google callback", extra={
"error_code": AUTHENTICATION_ERROR_CODES["GOOGLE_OAUTH_PROVIDER_ERROR"],
"error_message": "GOOGLE_OAUTH_PROVIDER_ERROR",
})
logger.warning(
"Code not found in Google callback",
extra={
"error_code": AUTHENTICATION_ERROR_CODES["GOOGLE_OAUTH_PROVIDER_ERROR"],
"error_message": "GOOGLE_OAUTH_PROVIDER_ERROR",
},
)
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GOOGLE_OAUTH_PROVIDER_ERROR"],
error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
@@ -48,9 +48,7 @@ class SAMLAuthInitiateEndpoint(View):
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
url = urljoin(base_host(request=request, is_app=True), "?" + urlencode(params))
return HttpResponseRedirect(url)
@@ -23,9 +23,7 @@ class SignOutAuthEndpoint(View):
# Check if the last medium of user is oidc
if request.user.last_login_medium == "oidc":
provider = OIDCOAuthProvider(request=request)
logout_url = provider.logout(
logout_url=f"{base_host(request=request, is_app=True)}/auth/oidc/logout/"
)
logout_url = provider.logout(logout_url=f"{base_host(request=request, is_app=True)}/auth/oidc/logout/")
if logout_url:
return HttpResponseRedirect(logout_url)
@@ -28,9 +28,7 @@ class OAuthApplicationInstalledWorkspacesEndpoint(BaseAPIView):
if request.query_params.get("id"):
filters["id"] = request.query_params.get("id")
workspace_applications = WorkspaceAppInstallation.objects.filter(
application=application, **filters
)
workspace_applications = WorkspaceAppInstallation.objects.filter(application=application, **filters)
# Always filter those workspaces where user is a member
workspace_applications = workspace_applications.filter(
@@ -42,9 +40,5 @@ class OAuthApplicationInstalledWorkspacesEndpoint(BaseAPIView):
workspace=token.workspace,
)
workspace_applications_serializer = WorkspaceAppInstallationSerializer(
workspace_applications, many=True
)
return Response(
workspace_applications_serializer.data, status=status.HTTP_200_OK
)
workspace_applications_serializer = WorkspaceAppInstallationSerializer(workspace_applications, many=True)
return Response(workspace_applications_serializer.data, status=status.HTTP_200_OK)
@@ -36,11 +36,7 @@ APP_INSTALLATION_ID_CACHE_TTL = 60 # 1 minute
class OAuthTokenEndpoint(TokenView):
"""OAuth token endpoint with rate limiting (5/minute)"""
@method_decorator(
ratelimit(
key=token_ratelimit_key, rate=TOKEN_RATE_LIMIT, block=False, group="token"
)
)
@method_decorator(ratelimit(key=token_ratelimit_key, rate=TOKEN_RATE_LIMIT, block=False, group="token"))
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
# Check if rate limited before proceeding
if getattr(request, "limited", False):
@@ -53,13 +49,9 @@ class OAuthTokenEndpoint(TokenView):
)
response = super().dispatch(request, *args, **kwargs)
return add_ratelimit_headers(
request, response, TOKEN_RATE_LIMIT, token_ratelimit_key, group="token"
)
return add_ratelimit_headers(request, response, TOKEN_RATE_LIMIT, token_ratelimit_key, group="token")
def create_token_response(
self, request: HttpRequest
) -> Tuple[int, Dict[str, str], bytes, int]:
def create_token_response(self, request: HttpRequest) -> Tuple[int, Dict[str, str], bytes, int]:
token_response = super().create_token_response(request)
_, headers, token_data, status_code = token_response
token_data = json.loads(token_data)
@@ -81,19 +73,15 @@ class OAuthTokenEndpoint(TokenView):
raise exceptions.ValidationError("App installation ID is required")
else:
# get the workspace app installation
workspace_app_installation = (
WorkspaceAppInstallation.objects.filter(
id=app_installation_id,
application_id=application_id,
status=WorkspaceAppInstallation.Status.INSTALLED,
).first()
)
workspace_app_installation = WorkspaceAppInstallation.objects.filter(
id=app_installation_id,
application_id=application_id,
status=WorkspaceAppInstallation.Status.INSTALLED,
).first()
# if the workspace app installation is not found, delete the token
if not workspace_app_installation:
token.delete()
raise exceptions.ValidationError(
"Workspace application not found"
)
raise exceptions.ValidationError("Workspace application not found")
# set the bot user, workspace app installation and workspace on the token
token.user = workspace_app_installation.app_bot
@@ -113,11 +101,7 @@ AUTHORIZE_RATE_LIMIT = "10/m"
class CustomAuthorizationView(AuthorizationView):
"""OAuth authorization view with rate limiting (10/minute)"""
@method_decorator(
ratelimit(
key=auth_ratelimit_key, rate=AUTHORIZE_RATE_LIMIT, block=False, group="auth"
)
)
@method_decorator(ratelimit(key=auth_ratelimit_key, rate=AUTHORIZE_RATE_LIMIT, block=False, group="auth"))
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
# Check if rate limited before proceeding
if getattr(request, "limited", False):
@@ -130,9 +114,7 @@ class CustomAuthorizationView(AuthorizationView):
)
response = super().dispatch(request, *args, **kwargs)
return add_ratelimit_headers(
request, response, AUTHORIZE_RATE_LIMIT, auth_ratelimit_key, group="auth"
)
return add_ratelimit_headers(request, response, AUTHORIZE_RATE_LIMIT, auth_ratelimit_key, group="auth")
def handle_no_permission(self) -> HttpResponseRedirect:
# Redirect to login with the current URL as the next path
@@ -185,13 +167,11 @@ class CustomOAuth2Validator(OAuth2Validator):
# Allow 'code' response type for all applications
if response_type == "code":
return True
# For other response types, use the default validation
return super().validate_response_type(client_id, response_type, client, request, *args, **kwargs)
def validate_grant_type(
self, client_id, grant_type, client, request, *args, **kwargs
):
def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs):
"""
Allow both authorization_code and client_credentials for app with authorization grant type authorization_code
and allow only client_credentials for app with authorization grant type client_credentials
@@ -227,9 +207,7 @@ class CustomOAuth2Validator(OAuth2Validator):
return None
# Get app_installation_id from cache
app_installation_id = cache.get(
f"app_installation_id_{client_id}_{request.user.id}"
)
app_installation_id = cache.get(f"app_installation_id_{client_id}_{request.user.id}")
if app_installation_id and grant:
try:
# Get the workspace app installation
@@ -255,9 +233,7 @@ class CustomOAuth2Validator(OAuth2Validator):
"""
Create the access token
"""
access_token = super()._create_access_token(
expires, request, token, source_refresh_token
)
access_token = super()._create_access_token(expires, request, token, source_refresh_token)
# get the grant and fetch grant
code = getattr(request, "code", None)
@@ -283,15 +259,11 @@ class CustomOAuth2Validator(OAuth2Validator):
access_token.save()
return access_token
def _create_refresh_token(
self, request, refresh_token_code, access_token, previous_refresh_token
):
def _create_refresh_token(self, request, refresh_token_code, access_token, previous_refresh_token):
"""
Create the refresh token
"""
refresh_token = super()._create_refresh_token(
request, refresh_token_code, access_token, previous_refresh_token
)
refresh_token = super()._create_refresh_token(request, refresh_token_code, access_token, previous_refresh_token)
# Get workspace info from the access_token
if access_token and hasattr(access_token, "workspace_app_installation"):
+1 -3
View File
@@ -27,6 +27,4 @@ class AutomationConfig(AppConfig):
importlib.import_module(f"{self.name}.nodes.{module_name}")
except ImportError as e:
# Log the error but don't fail startup
print(
f"Warning: Failed to import automation node module {module_name}: {e}"
)
print(f"Warning: Failed to import automation node module {module_name}: {e}")
+11 -33
View File
@@ -28,9 +28,7 @@ class AutomationConsumer:
"AUTOMATION_EVENT_STREAM_QUEUE_NAME",
"plane.event_stream.automations",
)
self.exchange_name = getattr(
settings, "AUTOMATION_EXCHANGE_NAME", "plane.event_stream"
)
self.exchange_name = getattr(settings, "AUTOMATION_EXCHANGE_NAME", "plane.event_stream")
self.prefetch_count = prefetch_count
# Consumer state
@@ -130,9 +128,7 @@ class AutomationConsumer:
# Bind to fanout exchange
channel.queue_bind(exchange=self.exchange_name, queue=self.queue_name)
logger.info(
f"Queue '{self.queue_name}' bound to exchange '{self.exchange_name}'"
)
logger.info(f"Queue '{self.queue_name}' bound to exchange '{self.exchange_name}'")
def _should_process(self, event_type: str) -> bool:
"""Check if event should be processed for automations."""
@@ -175,22 +171,16 @@ class AutomationConsumer:
# Ensure exactly-once processing with retry on connection issues
try:
ProcessedAutomationEvent.objects.create(
event_id=event_id, event_type=event_type, status="pending"
)
ProcessedAutomationEvent.objects.create(event_id=event_id, event_type=event_type, status="pending")
except IntegrityError:
logger.debug(f"Event {event_id} already processed")
return True
except OperationalError as e:
# Retry once on database connection issues
logger.warning(
f"Database error creating ProcessedAutomationEvent, retrying: {e}"
)
logger.warning(f"Database error creating ProcessedAutomationEvent, retrying: {e}")
self.ensure_database_connection()
try:
ProcessedAutomationEvent.objects.create(
event_id=event_id, event_type=event_type, status="pending"
)
ProcessedAutomationEvent.objects.create(event_id=event_id, event_type=event_type, status="pending")
except IntegrityError:
logger.debug(f"Event {event_id} already processed on retry")
return True
@@ -202,24 +192,16 @@ class AutomationConsumer:
# Update with task_id, also with retry logic
try:
ProcessedAutomationEvent.objects.filter(event_id=event_id).update(
task_id=task_result.id
)
ProcessedAutomationEvent.objects.filter(event_id=event_id).update(task_id=task_result.id)
except OperationalError as e:
logger.warning(
f"Database error updating ProcessedAutomationEvent, retrying: {e}"
)
logger.warning(f"Database error updating ProcessedAutomationEvent, retrying: {e}")
self.ensure_database_connection()
ProcessedAutomationEvent.objects.filter(event_id=event_id).update(
task_id=task_result.id
)
ProcessedAutomationEvent.objects.filter(event_id=event_id).update(task_id=task_result.id)
logger.info(f"Dispatched automation for {event_id} ({event_type})")
return True
except OperationalError as e:
logger.warning(
f"Database connection lost permanently, stopping consumer: {e}"
)
logger.warning(f"Database connection lost permanently, stopping consumer: {e}")
sys.exit(1)
except Exception as e:
logger.error(f"Error processing message: {e}")
@@ -248,9 +230,7 @@ class AutomationConsumer:
if success:
ch.basic_ack(delivery_tag=method.delivery_tag)
else:
ch.basic_nack(
delivery_tag=method.delivery_tag, requeue=False
)
ch.basic_nack(delivery_tag=method.delivery_tag, requeue=False)
self._consumer_tag = channel.basic_consume(
queue=self.queue_name, on_message_callback=message_callback
@@ -275,9 +255,7 @@ class AutomationConsumer:
except KeyboardInterrupt:
self._should_stop = True
except OperationalError:
logger.warning(
"Database connection lost permanently, stopping consumer"
)
logger.warning("Database connection lost permanently, stopping consumer")
sys.exit(1)
except Exception as e:
log_exception(e)
+23 -82
View File
@@ -118,9 +118,7 @@ class AutomationExecutionEngine:
"""Extract the triggering issue ID from the event, if present."""
try:
if entity_type == AutomationScopeChoices.WORKITEM:
return event.get("entity_id") or event.get("payload", {}).get(
"data", {}
).get("id")
return event.get("entity_id") or event.get("payload", {}).get("data", {}).get("id")
return None
except Exception:
return None
@@ -245,9 +243,7 @@ class AutomationExecutionEngine:
automation_run=automation_run,
)
if not condition_result.success:
return self._finalize_automation_failure(
automation_run, "Condition failed", condition_result
)
return self._finalize_automation_failure(automation_run, "Condition failed", condition_result)
# Step 2: Execute action nodes linearly
# If any action fails, stop execution and return failure
@@ -263,16 +259,12 @@ class AutomationExecutionEngine:
action_results.append(action_result)
if not action_result.success:
return self._finalize_automation_failure(
automation_run, f"Action {i + 1} failed", action_result
)
return self._finalize_automation_failure(automation_run, f"Action {i + 1} failed", action_result)
# All actions succeeded
# Use the last action result for the success response
final_result = action_results[-1] if action_results else None
return self._finalize_automation_success(
automation_run, "Automation completed successfully", final_result
)
return self._finalize_automation_success(automation_run, "Automation completed successfully", final_result)
def _execute_automation_after_trigger(
self,
@@ -307,14 +299,10 @@ class AutomationExecutionEngine:
# TODO: We're here because the trigger node was already validated in the dispatch_automation_event function.
validation_error = self._ensure_trigger_and_action_exist(nodes)
if validation_error:
return self._finalize_automation_failure(
automation_run, validation_error
)
return self._finalize_automation_failure(automation_run, validation_error)
# Create execution context
context = self._create_execution_context(
automation_run=automation_run, event=event
)
context = self._create_execution_context(automation_run=automation_run, event=event)
# Record trigger execution (already successful)
trigger_node = nodes.get(NodeTypeChoices.TRIGGER)
@@ -336,9 +324,7 @@ class AutomationExecutionEngine:
except Exception as e:
logger.exception("Unexpected error during triggered automation execution")
return self._finalize_automation_failure(
automation_run, f"Execution error: {str(e)}"
)
return self._finalize_automation_failure(automation_run, f"Execution error: {str(e)}")
def dispatch_automation_event(self, event: dict) -> List[Dict[str, Any]]:
"""
@@ -409,9 +395,7 @@ class AutomationExecutionEngine:
)
# Test the trigger - NO AutomationRun created yet
raw_trigger_result = trigger_node["handler"].execute(
event, context.to_dict()
)
raw_trigger_result = trigger_node["handler"].execute(event, context.to_dict())
# Only proceed if trigger matches
if not raw_trigger_result.get("success", False):
@@ -421,11 +405,7 @@ class AutomationExecutionEngine:
# Convert to structured result
trigger_result = NodeResult(
success=raw_trigger_result.get("success", False),
output={
k: v
for k, v in raw_trigger_result.items()
if k not in ["success", "error"]
},
output={k: v for k, v in raw_trigger_result.items() if k not in ["success", "error"]},
error=raw_trigger_result.get("error", ""),
)
@@ -434,16 +414,12 @@ class AutomationExecutionEngine:
# Ensure DB triggers mark events as originating from automations
try:
with connection.cursor() as cur:
cur.execute(
"SELECT set_config('plane.initiator_type', 'SYSTEM.AUTOMATION', true)"
)
cur.execute("SELECT set_config('plane.initiator_type', 'SYSTEM.AUTOMATION', true)")
except Exception:
# Fail-safe: do not block automation if GUC is unavailable
pass
entity_type = event.get(
"entity_type", AutomationScopeChoices.WORKITEM
)
entity_type = event.get("entity_type", AutomationScopeChoices.WORKITEM)
if entity_type == "issue":
entity_type = AutomationScopeChoices.WORKITEM
@@ -492,9 +468,7 @@ class AutomationExecutionEngine:
)
# Execute the full automation (trigger already succeeded)
result = self._execute_automation_after_trigger(
automation_run, event, trigger_result, nodes
)
result = self._execute_automation_after_trigger(automation_run, event, trigger_result, nodes)
results.append(result.to_dict()) # Convert back to dict for API
except Exception as e:
@@ -527,10 +501,7 @@ class AutomationExecutionEngine:
# Get all nodes for this version (optimize if not already prefetched)
# TODO: Need to double check the node fetching logic and how it's preserving the execution order
if (
hasattr(version, "_prefetched_objects_cache")
and "nodes" in version._prefetched_objects_cache
):
if hasattr(version, "_prefetched_objects_cache") and "nodes" in version._prefetched_objects_cache:
# Use prefetched nodes if available
automation_nodes = version.nodes.all()
else:
@@ -550,9 +521,7 @@ class AutomationExecutionEngine:
if node.node_type == NodeTypeChoices.ACTION:
if NodeTypeChoices.ACTION not in nodes:
nodes[NodeTypeChoices.ACTION] = []
nodes[NodeTypeChoices.ACTION].append(
{"handler": node_handler, "instance": node}
)
nodes[NodeTypeChoices.ACTION].append({"handler": node_handler, "instance": node})
else:
# Store trigger and condition nodes as single items
nodes[node.node_type] = {"handler": node_handler, "instance": node}
@@ -604,18 +573,12 @@ class AutomationExecutionEngine:
# Convert to structured result
node_result = NodeResult(
success=raw_result.get("success", False),
output={
k: v for k, v in raw_result.items() if k not in ["success", "error"]
},
output={k: v for k, v in raw_result.items() if k not in ["success", "error"]},
error=raw_result.get("error", ""),
)
# Update execution record
node_execution.status = (
RunStatusChoices.SUCCESS
if node_result.success
else RunStatusChoices.FAILED
)
node_execution.status = RunStatusChoices.SUCCESS if node_result.success else RunStatusChoices.FAILED
node_execution.completed_at = timezone.now()
node_execution.output_data = node_result.to_dict()
node_execution.error_message = node_result.error
@@ -625,9 +588,7 @@ class AutomationExecutionEngine:
except Exception as e:
# Update execution record with error
logger.exception(
f"Node execution failed: {getattr(node_instance, 'name', 'unknown')}"
)
logger.exception(f"Node execution failed: {getattr(node_instance, 'name', 'unknown')}")
node_execution.status = RunStatusChoices.FAILED
node_execution.completed_at = timezone.now()
node_execution.error_message = str(e)
@@ -663,9 +624,7 @@ class AutomationExecutionEngine:
NodeExecution.objects.create(
run=automation_run,
node=node_instance,
status=(
RunStatusChoices.SUCCESS if result.success else RunStatusChoices.FAILED
),
status=(RunStatusChoices.SUCCESS if result.success else RunStatusChoices.FAILED),
started_at=timezone.now(), # Approximate - actual execution was earlier
completed_at=timezone.now(),
input_data={"event": event},
@@ -748,40 +707,22 @@ def get_automation_status(automation_run_id: str) -> Dict[str, Any]:
automation_run = AutomationRun.objects.get(id=automation_run_id)
# Get node executions
node_executions = NodeExecution.objects.filter(
automation_run=automation_run
).order_by("started_at")
node_executions = NodeExecution.objects.filter(automation_run=automation_run).order_by("started_at")
return {
"automation_run_id": str(automation_run.id),
"automation_id": str(automation_run.automation.id),
"status": automation_run.status,
"started_at": (
automation_run.started_at.isoformat()
if automation_run.started_at
else None
),
"completed_at": (
automation_run.completed_at.isoformat()
if automation_run.completed_at
else None
),
"started_at": (automation_run.started_at.isoformat() if automation_run.started_at else None),
"completed_at": (automation_run.completed_at.isoformat() if automation_run.completed_at else None),
"result_data": automation_run.result_data,
"node_executions": [
{
"node_name": execution.node_name,
"node_type": execution.node_type,
"status": execution.status,
"started_at": (
execution.started_at.isoformat()
if execution.started_at
else None
),
"completed_at": (
execution.completed_at.isoformat()
if execution.completed_at
else None
),
"started_at": (execution.started_at.isoformat() if execution.started_at else None),
"completed_at": (execution.completed_at.isoformat() if execution.completed_at else None),
"output_data": execution.output_data,
"error_message": execution.error_message,
}
@@ -70,14 +70,10 @@ class Command(BaseCommand):
return
self.stdout.write(self.style.SUCCESS("🚀 Automation Creation Wizard"))
self.stdout.write(
"Creating a linear automation: Trigger → [Condition] → Action(s)\n"
)
self.stdout.write("Creating a linear automation: Trigger → [Condition] → Action(s)\n")
if options.get("dry_run"):
self.stdout.write(
self.style.WARNING("🔍 DRY RUN MODE - No changes will be made\n")
)
self.stdout.write(self.style.WARNING("🔍 DRY RUN MODE - No changes will be made\n"))
# Step 1: Get workspace and project
self._get_workspace_and_project(options)
@@ -100,18 +96,12 @@ class Command(BaseCommand):
# Step 7: Publish automation
self._publish_automation()
self.stdout.write(
self.style.SUCCESS(
f"\n✅ Automation '{self.automation.name}' created successfully!"
)
)
self.stdout.write(self.style.SUCCESS(f"\n✅ Automation '{self.automation.name}' created successfully!"))
self.stdout.write(f"Automation ID: {self.automation.id}")
self.stdout.write(f"Version: {self.version.version_number}")
except KeyboardInterrupt:
self.stdout.write(
self.style.ERROR("\n❌ Automation creation cancelled by user.")
)
self.stdout.write(self.style.ERROR("\n❌ Automation creation cancelled by user."))
except Exception as e:
self.stdout.write(self.style.ERROR(f"\n❌ Error creating automation: {e}"))
raise
@@ -139,9 +129,7 @@ class Command(BaseCommand):
# Get project
if not project_id:
project_prompt = (
f"📁 Enter Project ID (UUID) for workspace '{self.workspace.name}': "
)
project_prompt = f"📁 Enter Project ID (UUID) for workspace '{self.workspace.name}': "
project_id = self._get_safe_input(
project_prompt,
required=True,
@@ -152,10 +140,7 @@ class Command(BaseCommand):
self.project = Project.objects.get(id=project_id, workspace=self.workspace)
self.stdout.write(f"✅ Found project: {self.project.name}")
except Project.DoesNotExist:
error_msg = (
f"Project with ID {project_id} not found in workspace "
f"{self.workspace.name}"
)
error_msg = f"Project with ID {project_id} not found in workspace {self.workspace.name}"
raise CommandError(error_msg)
except ValueError:
raise CommandError(f"Invalid project ID format: {project_id}")
@@ -173,9 +158,7 @@ class Command(BaseCommand):
self.stdout.write(self.style.HTTP_INFO("\n📝 Automation Details"))
name = self._get_safe_input("Enter automation name: ", required=True)
description = self._get_safe_input(
"Enter automation description (optional): ", required=False
)
description = self._get_safe_input("Enter automation description (optional): ", required=False)
# Show available scopes
self.stdout.write("\nAvailable scopes:")
@@ -206,11 +189,7 @@ class Command(BaseCommand):
self.stdout.write(self.style.HTTP_INFO("\n🎯 Trigger Node"))
# Get available triggers
available_triggers = {
name: meta
for name, meta in self.registry.all().items()
if meta.node_type == "trigger"
}
available_triggers = {name: meta for name, meta in self.registry.all().items() if meta.node_type == "trigger"}
if not available_triggers:
raise CommandError("No trigger handlers available")
@@ -270,9 +249,7 @@ class Command(BaseCommand):
# Get available conditions
available_conditions = {
name: meta
for name, meta in self.registry.all().items()
if meta.node_type == "condition"
name: meta for name, meta in self.registry.all().items() if meta.node_type == "condition"
}
if not available_conditions:
@@ -326,11 +303,7 @@ class Command(BaseCommand):
self.stdout.write(self.style.HTTP_INFO("\n⚡ Action Node(s)"))
# Get available actions
available_actions = {
name: meta
for name, meta in self.registry.all().items()
if meta.node_type == "action"
}
available_actions = {name: meta for name, meta in self.registry.all().items() if meta.node_type == "action"}
if not available_actions:
raise CommandError("No action handlers available")
@@ -343,9 +316,7 @@ class Command(BaseCommand):
self.stdout.write("Available actions:")
for i, (name, meta) in enumerate(available_actions.items(), 1):
# Get description from schema if available
schema_info = (
meta.schema.schema() if hasattr(meta.schema, "schema") else {}
)
schema_info = meta.schema.schema() if hasattr(meta.schema, "schema") else {}
description = schema_info.get("description", "No description available")
self.stdout.write(f" {i}. {name} - {description}")
@@ -485,9 +456,7 @@ class Command(BaseCommand):
self.automation.is_enabled = True
self.automation.save()
self.stdout.write(
f"✅ Published automation version {published_version.version_number}"
)
self.stdout.write(f"✅ Published automation version {published_version.version_number}")
self.stdout.write("🎉 Automation is now active!")
# Display summary
@@ -497,9 +466,7 @@ class Command(BaseCommand):
"""Display a summary of the created automation."""
self.stdout.write(self.style.HTTP_INFO("\n📋 Automation Summary"))
self.stdout.write(f"Name: {self.automation.name}")
self.stdout.write(
f"Description: {self.automation.description or 'No description'}"
)
self.stdout.write(f"Description: {self.automation.description or 'No description'}")
self.stdout.write(f"Scope: {self.automation.scope}")
self.stdout.write(f"Workspace: {self.workspace.name}")
self.stdout.write(f"Project: {self.project.name}")
@@ -538,9 +505,7 @@ class Command(BaseCommand):
# Get description from schema
has_schema = hasattr(meta.schema, "schema")
schema_info = meta.schema.schema() if has_schema else {}
description = schema_info.get(
"description", "No description available"
)
description = schema_info.get("description", "No description available")
# Get required fields
properties = schema_info.get("properties", {})
@@ -556,10 +521,7 @@ class Command(BaseCommand):
field_type = field_info.get("type", "string")
field_desc = field_info.get("description", "")
req_marker = " *" if is_required else ""
param_line = (
f" - {field_name} ({field_type})"
f"{req_marker}: {field_desc}"
)
param_line = f" - {field_name} ({field_type}){req_marker}: {field_desc}"
self.stdout.write(param_line)
else:
self.stdout.write(" Parameters: None")
@@ -579,9 +541,7 @@ class Command(BaseCommand):
return default
return response in ("y", "yes") if not default else response not in ("n", "no")
def _get_safe_input(
self, prompt: str, required: bool = True, validator=None
) -> str:
def _get_safe_input(self, prompt: str, required: bool = True, validator=None) -> str:
"""Get user input with validation and error handling."""
while True:
try:
@@ -52,9 +52,7 @@ class Command(BaseCommand):
"AUTOMATION_EVENT_STREAM_QUEUE_NAME",
"plane.event_stream.automations",
)
exchange_name = getattr(
settings, "AUTOMATION_EXCHANGE_NAME", "plane.event_stream"
)
exchange_name = getattr(settings, "AUTOMATION_EXCHANGE_NAME", "plane.event_stream")
event_types = getattr(settings, "AUTOMATION_EVENT_TYPES", ["issue."])
event_types_display = ", ".join(event_types)
@@ -77,25 +75,17 @@ class Command(BaseCommand):
)
try:
self.stdout.write(
self.style.SUCCESS(
"Consumer initialized. Starting message processing..."
)
)
self.stdout.write(self.style.SUCCESS("Consumer initialized. Starting message processing..."))
# Start the consumer (this will block until stopped)
consumer.start_consuming()
except KeyboardInterrupt:
self.stdout.write(
self.style.WARNING("Keyboard interrupt received, stopping...")
)
self.stdout.write(self.style.WARNING("Keyboard interrupt received, stopping..."))
except Exception as e:
self.stdout.write(self.style.ERROR(f"Unexpected error: {e}"))
sys.exit(1)
finally:
self.stdout.write(
self.style.SUCCESS("Automation consumer stopped successfully.")
)
self.stdout.write(self.style.SUCCESS("Automation consumer stopped successfully."))
+41 -100
View File
@@ -45,16 +45,11 @@ class AddCommentParams(BaseModel):
comment_text: str = Field(
...,
description=(
"Comment text to add (supports template variables like {{payload.data.priority}})"
),
description=("Comment text to add (supports template variables like {{payload.data.priority}})"),
examples=[
"Issue priority changed to {{payload.data.priority}}",
"Issue assigned to user ID: {{payload.data.assignee_ids.0}}",
(
"State changed from {{payload.previous_attributes.state_id}} "
"to {{payload.data.state_id}}"
),
("State changed from {{payload.previous_attributes.state_id}} to {{payload.data.state_id}}"),
],
)
@@ -166,9 +161,7 @@ class AddCommentAction(ActionNode):
}
# Render the comment template
rendered_comment = self.render_template(
self.params.comment_text, event, context
)
rendered_comment = self.render_template(self.params.comment_text, event, context)
if not rendered_comment.strip():
return {
@@ -202,17 +195,19 @@ class AddCommentAction(ActionNode):
requested_data = IssueCommentSerializer(comment).data
# Ensure the activity fires only AFTER the commit
transaction.on_commit(lambda: issue_activity.delay(
type="comment.activity.created",
requested_data=json.dumps(requested_data, cls=DjangoJSONEncoder),
actor_id=str(automation_user_id),
issue_id=str(issue_id),
project_id=str(issue.project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=None,
))
transaction.on_commit(
lambda: issue_activity.delay(
type="comment.activity.created",
requested_data=json.dumps(requested_data, cls=DjangoJSONEncoder),
actor_id=str(automation_user_id),
issue_id=str(issue_id),
project_id=str(issue.project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=None,
)
)
return {
"success": True,
@@ -230,9 +225,7 @@ class AddCommentAction(ActionNode):
"action": "add_comment",
}
def _get_assets_for_comment(
self, issue: Issue, context: dict, automation_user_id: str
) -> None:
def _get_assets_for_comment(self, issue: Issue, context: dict, automation_user_id: str) -> None:
"""
Step 1: Extract asset ids from the FileAsset model
Step 2: Duplicate the assets
@@ -258,9 +251,7 @@ class AddCommentAction(ActionNode):
)
if duplicated_assets:
external_data = sync_with_external_service(
"automation_comment", duplicated_assets
)
external_data = sync_with_external_service("automation_comment", duplicated_assets)
return {
"comment_html": external_data.get("comment_html"),
@@ -323,10 +314,7 @@ class ChangePropertyParams(BaseModel):
}
if v not in allowed_properties:
raise ValueError(
f"Property '{v}' is not allowed. "
f"Allowed properties: {allowed_properties}"
)
raise ValueError(f"Property '{v}' is not allowed. Allowed properties: {allowed_properties}")
return v
@@ -402,9 +390,7 @@ class ChangePropertyAction(ActionNode):
return rendered
def _handle_priority_property(
self, issue: Issue, change_type: str, values: List[Any]
) -> Any:
def _handle_priority_property(self, issue: Issue, change_type: str, values: List[Any]) -> Any:
"""Handle priority property changes."""
valid_priorities = ["urgent", "high", "medium", "low", "none"]
@@ -414,10 +400,7 @@ class ChangePropertyAction(ActionNode):
new_priority = str(values[0]) if values[0] is not None else None
if new_priority and new_priority not in valid_priorities:
raise ValueError(
f"Invalid priority '{new_priority}'. "
f"Must be one of: {valid_priorities}"
)
raise ValueError(f"Invalid priority '{new_priority}'. Must be one of: {valid_priorities}")
return new_priority
elif change_type == "remove":
@@ -425,9 +408,7 @@ class ChangePropertyAction(ActionNode):
return issue.priority
def _handle_state_property(
self, issue: Issue, change_type: str, values: List[Any]
) -> Any:
def _handle_state_property(self, issue: Issue, change_type: str, values: List[Any]) -> Any:
"""Handle state property changes."""
if change_type in ["update", "add"]:
if not values:
@@ -455,13 +436,9 @@ class ChangePropertyAction(ActionNode):
return issue.state
def _handle_assignees_property(
self, issue: Issue, change_type: str, values: List[Any]
) -> Any:
def _handle_assignees_property(self, issue: Issue, change_type: str, values: List[Any]) -> Any:
"""Handle assignees property changes."""
current_assignees = [
str(a) for a in issue.assignees.values_list("id", flat=True)
]
current_assignees = [str(a) for a in issue.assignees.values_list("id", flat=True)]
current_set = set(current_assignees)
provided_set = {str(v) for v in values if v is not None}
@@ -477,9 +454,7 @@ class ChangePropertyAction(ActionNode):
"assignee_ids_to_remove": assignee_ids_to_remove,
}
def _handle_labels_property(
self, issue: Issue, change_type: str, values: List[Any]
) -> Any:
def _handle_labels_property(self, issue: Issue, change_type: str, values: List[Any]) -> Any:
"""Handle labels property changes."""
current_labels = list(issue.labels.values_list("id", flat=True))
@@ -492,9 +467,7 @@ class ChangePropertyAction(ActionNode):
elif change_type == "remove":
# Remove specified labels
labels_to_remove = [str(v) for v in values if v is not None]
remaining_labels = [
label for label in current_labels if str(label) not in labels_to_remove
]
remaining_labels = [label for label in current_labels if str(label) not in labels_to_remove]
return remaining_labels
elif change_type == "update":
@@ -504,9 +477,7 @@ class ChangePropertyAction(ActionNode):
return current_labels
def _handle_date_property(
self, issue: Issue, change_type: str, values: List[Any], property_name: str
) -> Any:
def _handle_date_property(self, issue: Issue, change_type: str, values: List[Any], property_name: str) -> Any:
"""Handle date property changes (start_date, due_date)."""
if change_type in ["update", "add"]:
if not values:
@@ -524,19 +495,12 @@ class ChangePropertyAction(ActionNode):
parsed_date = datetime.strptime(date_value, "%Y-%m-%d").date()
return parsed_date
else: # ISO datetime
parsed_date = datetime.fromisoformat(
date_value.replace("Z", "+00:00")
).date()
parsed_date = datetime.fromisoformat(date_value.replace("Z", "+00:00")).date()
return parsed_date
except ValueError:
raise ValueError(
f"Invalid date format '{date_value}'. "
"Use YYYY-MM-DD or ISO datetime"
)
raise ValueError(f"Invalid date format '{date_value}'. Use YYYY-MM-DD or ISO datetime")
else:
logging.info(
f"Date value is not string, returning as-is: {date_value} (type: {type(date_value)})"
)
logging.info(f"Date value is not string, returning as-is: {date_value} (type: {type(date_value)})")
return date_value
@@ -559,10 +523,7 @@ class ChangePropertyAction(ActionNode):
ArrayAgg(
"labels__id",
distinct=True,
filter=Q(
~Q(labels__id__isnull=True)
& Q(label_issue__deleted_at__isnull=True)
),
filter=Q(~Q(labels__id__isnull=True) & Q(label_issue__deleted_at__isnull=True)),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -610,9 +571,7 @@ class ChangePropertyAction(ActionNode):
"action": "change_property",
}
automation_bot_user_id = Automation.objects.get(
id=context.get("automation_id")
).bot_user_id
automation_bot_user_id = Automation.objects.get(id=context.get("automation_id")).bot_user_id
issue.updated_by_id = automation_bot_user_id
# Render property values if they contain templates
@@ -639,25 +598,19 @@ class ChangePropertyAction(ActionNode):
# Dispatch to appropriate property handler
if property_name == "priority":
new_value = self._handle_priority_property(
issue, self.params.change_type, rendered_values
)
new_value = self._handle_priority_property(issue, self.params.change_type, rendered_values)
issue.priority = new_value
update_fields = ["priority", "updated_at"]
elif property_name == "state_id":
new_state = self._handle_state_property(
issue, self.params.change_type, rendered_values
)
new_state = self._handle_state_property(issue, self.params.change_type, rendered_values)
# Set the state object, not just the ID
issue.state = new_state
new_value = str(new_state.id) if new_state else None
update_fields = ["state_id", "updated_at"]
elif property_name == "assignee_ids":
changes = self._handle_assignees_property(
issue, self.params.change_type, rendered_values
)
changes = self._handle_assignees_property(issue, self.params.change_type, rendered_values)
assignees_to_add = set(changes.get("assignee_ids_to_add", []))
assignees_to_remove = set(changes.get("assignee_ids_to_remove", []))
current_assignees_set = {str(a) for a in (old_value or [])}
@@ -684,18 +637,14 @@ class ChangePropertyAction(ActionNode):
ignore_conflicts=True,
)
final_assignees = list(
(current_assignees_set - assignees_to_remove) | assignees_to_add
)
final_assignees = list((current_assignees_set - assignees_to_remove) | assignees_to_add)
old_value = str(old_value) if old_value else None
new_value = str(final_assignees) if final_assignees else None
update_fields = ["updated_at"]
elif property_name == "label_ids":
new_label_ids = self._handle_labels_property(
issue, self.params.change_type, rendered_values
)
new_label_ids = self._handle_labels_property(issue, self.params.change_type, rendered_values)
current_label_ids_set = {str(l) for l in (old_value or [])}
desired_label_ids_set = {str(l) for l in (new_label_ids or [])}
@@ -704,9 +653,7 @@ class ChangePropertyAction(ActionNode):
labels_to_add = list(desired_label_ids_set - current_label_ids_set)
if labels_to_remove:
IssueLabel.objects.using("default").filter(
issue=issue, label_id__in=labels_to_remove
).delete()
IssueLabel.objects.using("default").filter(issue=issue, label_id__in=labels_to_remove).delete()
if labels_to_add:
IssueLabel.objects.bulk_create(
@@ -726,24 +673,18 @@ class ChangePropertyAction(ActionNode):
)
old_value = str(old_value) if old_value else None
new_value = (
str(list(desired_label_ids_set)) if desired_label_ids_set else None
)
new_value = str(list(desired_label_ids_set)) if desired_label_ids_set else None
update_fields = ["updated_at"]
elif property_name == "start_date":
new_value = self._handle_date_property(
issue, self.params.change_type, rendered_values, property_name
)
new_value = self._handle_date_property(issue, self.params.change_type, rendered_values, property_name)
issue.start_date = new_value
old_value = str(old_value) if old_value else None
new_value = str(new_value) if new_value else None
update_fields = ["start_date", "updated_at"]
elif property_name == "target_date":
new_value = self._handle_date_property(
issue, self.params.change_type, rendered_values, property_name
)
new_value = self._handle_date_property(issue, self.params.change_type, rendered_values, property_name)
issue.target_date = new_value
old_value = str(old_value) if old_value else None
new_value = str(new_value) if new_value else None
+26 -71
View File
@@ -46,36 +46,30 @@ class ConditionNode(BaseAutomationNode):
def _hydrate_related_ids(self, event: dict, field_path: str) -> Any:
"""Fetch related IDs from DB when not present in payload."""
try:
issue_id = event.get("entity_id") or (
event.get("payload", {}).get("data", {}).get("id")
)
issue_id = event.get("entity_id") or (event.get("payload", {}).get("data", {}).get("id"))
if not issue_id:
return None
if field_path == "payload.data.assignee_ids":
ids = list(
IssueAssignee.objects.filter(
issue_id=issue_id, deleted_at__isnull=True
).values_list("assignee_id", flat=True)
IssueAssignee.objects.filter(issue_id=issue_id, deleted_at__isnull=True).values_list(
"assignee_id", flat=True
)
)
result = [str(x) for x in ids]
# Persist into event to avoid repeated queries
event.setdefault("payload", {}).setdefault("data", {})[
"assignee_ids"
] = result
event.setdefault("payload", {}).setdefault("data", {})["assignee_ids"] = result
return result
if field_path == "payload.data.label_ids":
ids = list(
IssueLabel.objects.filter(
issue_id=issue_id, deleted_at__isnull=True
).values_list("label_id", flat=True)
IssueLabel.objects.filter(issue_id=issue_id, deleted_at__isnull=True).values_list(
"label_id", flat=True
)
)
result = [str(x) for x in ids]
# Persist into event to avoid repeated queries
event.setdefault("payload", {}).setdefault("data", {})[
"label_ids"
] = result
event.setdefault("payload", {}).setdefault("data", {})["label_ids"] = result
return result
return None
@@ -146,9 +140,7 @@ class JSONFilterParams(BaseModel):
elif "field" in node:
# Field comparison
if "operator" not in node or "value" not in node:
raise ValueError(
"Field comparison must have 'operator' and 'value'"
)
raise ValueError("Field comparison must have 'operator' and 'value'")
valid_operators = {
# New preferred operator (can accept single value or array)
@@ -163,13 +155,9 @@ class JSONFilterParams(BaseModel):
"lte",
}
if node["operator"] not in valid_operators:
raise ValueError(
f"Invalid operator '{node['operator']}'. Valid operators: {valid_operators}"
)
raise ValueError(f"Invalid operator '{node['operator']}'. Valid operators: {valid_operators}")
else:
raise ValueError(
"Filter node must contain 'and', 'or', 'not', or 'field'"
)
raise ValueError("Filter node must contain 'and', 'or', 'not', or 'field'")
validate_node(v)
return v
@@ -193,9 +181,7 @@ class JSONFilterCondition(ConditionNode):
def execute(self, event: dict, context: dict) -> Dict[str, Any]:
"""Evaluate the JSON filter and return consistent success/failure format."""
try:
filter_result = self._evaluate_filter_node(
self.params.filter_expression, event, context
)
filter_result = self._evaluate_filter_node(self.params.filter_expression, event, context)
if filter_result:
return {
@@ -227,28 +213,20 @@ class JSONFilterCondition(ConditionNode):
},
}
def _evaluate_filter_node(
self, node: Dict[str, Any], event: dict, context: dict
) -> bool:
def _evaluate_filter_node(self, node: Dict[str, Any], event: dict, context: dict) -> bool:
"""Recursively evaluate a filter node."""
if "and" in node:
conditions = node["and"]
# Vacuous truth: AND over empty set is True
if isinstance(conditions, list) and len(conditions) == 0:
return True
return all(
self._evaluate_filter_node(condition, event, context)
for condition in conditions
)
return all(self._evaluate_filter_node(condition, event, context) for condition in conditions)
elif "or" in node:
conditions = node["or"]
# Identity for OR over empty set is False
if isinstance(conditions, list) and len(conditions) == 0:
return False
return any(
self._evaluate_filter_node(condition, event, context)
for condition in conditions
)
return any(self._evaluate_filter_node(condition, event, context) for condition in conditions)
elif "not" in node:
condition = node["not"]
if isinstance(condition, list):
@@ -257,10 +235,7 @@ class JSONFilterCondition(ConditionNode):
# consider empty NOT as True (no-op)
if len(condition) == 0:
return True
return not all(
self._evaluate_filter_node(cond, event, context)
for cond in condition
)
return not all(self._evaluate_filter_node(cond, event, context) for cond in condition)
return not self._evaluate_filter_node(condition, event, context)
elif "field" in node:
field_path = node["field"]
@@ -271,18 +246,13 @@ class JSONFilterCondition(ConditionNode):
else:
return False
def _compare_values(
self, field_value: Any, operator: str, expected_value: Any
) -> bool:
def _compare_values(self, field_value: Any, operator: str, expected_value: Any) -> bool:
"""Compare field value against expected value using the specified operator."""
try:
if operator == "is":
# Accept both single value and list of values
if isinstance(expected_value, list):
return any(
self._equals_comparison(field_value, val)
for val in expected_value
)
return any(self._equals_comparison(field_value, val) for val in expected_value)
return self._equals_comparison(field_value, expected_value)
if operator == "equals":
@@ -291,19 +261,14 @@ class JSONFilterCondition(ConditionNode):
elif operator == "in":
if not isinstance(expected_value, list):
return False
return any(
self._equals_comparison(field_value, val) for val in expected_value
)
return any(self._equals_comparison(field_value, val) for val in expected_value)
elif operator == "contains":
if field_value is None:
return False
# If the field value is a list, check any item's string contains expected_value
if isinstance(field_value, list):
return any(
str(expected_value).lower() in str(item).lower()
for item in field_value
)
return any(str(expected_value).lower() in str(item).lower() for item in field_value)
return str(expected_value).lower() in str(field_value).lower()
elif operator in ["gt", "gte", "lt", "lte"]:
@@ -322,12 +287,8 @@ class JSONFilterCondition(ConditionNode):
# If either side is a list, treat as set-like overlap: any pair equals
if isinstance(field_value, list) or isinstance(expected_value, list):
field_items = (
field_value if isinstance(field_value, list) else [field_value]
)
expected_items = (
expected_value if isinstance(expected_value, list) else [expected_value]
)
field_items = field_value if isinstance(field_value, list) else [field_value]
expected_items = expected_value if isinstance(expected_value, list) else [expected_value]
for field_item in field_items:
for expected_item in expected_items:
if self._equals_comparison(field_item, expected_item):
@@ -339,17 +300,13 @@ class JSONFilterCondition(ConditionNode):
return self._date_equals_comparison(field_value, expected_value)
# Handle numeric comparisons (int/float)
if isinstance(field_value, (int, float)) and isinstance(
expected_value, (int, float)
):
if isinstance(field_value, (int, float)) and isinstance(expected_value, (int, float)):
return field_value == expected_value
# String comparison (case-insensitive)
return str(field_value).lower() == str(expected_value).lower()
def _numeric_comparison(
self, field_value: Any, operator: str, expected_value: Any
) -> bool:
def _numeric_comparison(self, field_value: Any, operator: str, expected_value: Any) -> bool:
"""Perform numeric comparison with type coercion."""
try:
# Handle date comparisons
@@ -377,9 +334,7 @@ class JSONFilterCondition(ConditionNode):
return False
def _date_comparison(
self, field_value: Any, operator: str, expected_value: Any
) -> bool:
def _date_comparison(self, field_value: Any, operator: str, expected_value: Any) -> bool:
"""Perform date comparison with flexible parsing."""
try:
field_date = self._parse_date(field_value)
+1 -3
View File
@@ -48,9 +48,7 @@ class NodeRegistry:
# ---------------------------------------------------------------------
# API
# ---------------------------------------------------------------------
def register(
self, name: str, node_type: str, handler: Callable, schema: Type[BaseModel]
):
def register(self, name: str, node_type: str, handler: Callable, schema: Type[BaseModel]):
if name in self._registry:
raise ValueError(f"Node '{name}' already registered")
self._registry[name] = NodeMeta(name, node_type, handler, schema)
+4 -12
View File
@@ -64,9 +64,7 @@ def execute_automation_task(self, event_data: Dict[str, Any]) -> Dict[str, Any]:
with transaction.atomic():
# Mark event as processing
# TODO: Do we need this logic? (might be required when we have multiple consumers running)
updated_count = ProcessedAutomationEvent.mark_processing(
event_id=event_id, task_id=self.request.id
)
updated_count = ProcessedAutomationEvent.mark_processing(event_id=event_id, task_id=self.request.id)
if updated_count == 0:
# Event might have been processed by another task or doesn't exist
@@ -85,9 +83,7 @@ def execute_automation_task(self, event_data: Dict[str, Any]) -> Dict[str, Any]:
# Check if any automations were triggered
if automation_results and len(automation_results) > 0:
logger.info(
f"Event {event_id} triggered {len(automation_results)} automations"
)
logger.info(f"Event {event_id} triggered {len(automation_results)} automations")
# Mark as completed
ProcessedAutomationEvent.mark_completed(event_id)
@@ -96,9 +92,7 @@ def execute_automation_task(self, event_data: Dict[str, Any]) -> Dict[str, Any]:
"success": True,
"event_id": event_id,
"automations_triggered": len(automation_results),
"automation_runs": [
result.get("automation_run_id") for result in automation_results
],
"automation_runs": [result.get("automation_run_id") for result in automation_results],
}
else:
# No automations triggered, but still successful
@@ -119,9 +113,7 @@ def execute_automation_task(self, event_data: Dict[str, Any]) -> Dict[str, Any]:
# Mark as failed
try:
ProcessedAutomationEvent.mark_failed(
event_id=event_id, error_message=str(e), increment_retry=True
)
ProcessedAutomationEvent.mark_failed(event_id=event_id, error_message=str(e), increment_retry=True)
except Exception as mark_error:
logger.error(f"Failed to mark event {event_id} as failed: {mark_error}")
+26 -87
View File
@@ -34,10 +34,7 @@ def create_workspace_members(workspace, members):
members = User.objects.filter(email__in=members)
_ = WorkspaceMember.objects.bulk_create(
[
WorkspaceMember(workspace=workspace, member=member, role=20)
for member in members
],
[WorkspaceMember(workspace=workspace, member=member, role=20) for member in members],
ignore_conflicts=True,
)
return
@@ -49,9 +46,7 @@ def create_project(workspace, user_id):
project = Project.objects.create(
workspace=workspace,
name=name,
identifier=name[
: random.randint(2, 12 if len(name) - 1 >= 12 else len(name) - 1)
].upper(),
identifier=name[: random.randint(2, 12 if len(name) - 1 >= 12 else len(name) - 1)].upper(),
created_by_id=user_id,
)
@@ -167,17 +162,11 @@ def create_cycles(workspace, project, user_id, cycle_count):
)
# Ensure end_date is strictly after start_date if start_date is not None
while start_date is not None and (
end_date <= start_date or (start_date, end_date) in used_date_ranges
):
while start_date is not None and (end_date <= start_date or (start_date, end_date) in used_date_ranges):
end_date = fake.date_this_year()
# Add the unique date range to the set
(
used_date_ranges.add((start_date, end_date))
if (end_date is not None and start_date is not None)
else None
)
(used_date_ranges.add((start_date, end_date)) if (end_date is not None and start_date is not None) else None)
# Append the cycle with unique date range
cycles.append(
@@ -235,9 +224,7 @@ def create_issues(workspace, project, user_id, issue_count):
issues = []
# Get the maximum sequence_id
last_id = IssueSequence.objects.filter(project=project).aggregate(
largest=Max("sequence")
)["largest"]
last_id = IssueSequence.objects.filter(project=project).aggregate(largest=Max("sequence"))["largest"]
last_id = 1 if last_id is None else last_id + 1
@@ -246,9 +233,7 @@ def create_issues(workspace, project, user_id, issue_count):
project=project, state_id=states[random.randint(0, len(states) - 1)]
).aggregate(largest=Max("sort_order"))["largest"]
largest_sort_order = (
65535 if largest_sort_order is None else largest_sort_order + 10000
)
largest_sort_order = 65535 if largest_sort_order is None else largest_sort_order + 10000
for _ in range(0, issue_count):
start_date = [None, fake.date_this_year()][random.randint(0, 1)]
@@ -274,9 +259,7 @@ def create_issues(workspace, project, user_id, issue_count):
sort_order=largest_sort_order,
start_date=start_date,
target_date=end_date,
priority=["urgent", "high", "medium", "low", "none"][
random.randint(0, 4)
],
priority=["urgent", "high", "medium", "low", "none"][random.randint(0, 4)],
created_by_id=creators[random.randint(0, len(creators) - 1)],
)
)
@@ -321,12 +304,8 @@ def create_issues(workspace, project, user_id, issue_count):
def create_issue_parent(workspace, project, user_id, issue_count):
parent_count = issue_count / 4
parent_issues = Issue.objects.filter(project=project).values_list("id", flat=True)[
: int(parent_count)
]
sub_issues = Issue.objects.filter(project=project).exclude(pk__in=parent_issues)[
: int(issue_count / 2)
]
parent_issues = Issue.objects.filter(project=project).values_list("id", flat=True)[: int(parent_count)]
sub_issues = Issue.objects.filter(project=project).exclude(pk__in=parent_issues)[: int(issue_count / 2)]
bulk_sub_issues = []
for sub_issue in sub_issues:
@@ -337,9 +316,7 @@ def create_issue_parent(workspace, project, user_id, issue_count):
def create_issue_assignees(workspace, project, user_id, issue_count):
# assignees
assignees = ProjectMember.objects.filter(project=project).values_list(
"member_id", flat=True
)
assignees = ProjectMember.objects.filter(project=project).values_list("member_id", flat=True)
issues = random.sample(
list(Issue.objects.filter(project=project).values_list("id", flat=True)),
int(issue_count / 2),
@@ -348,9 +325,7 @@ def create_issue_assignees(workspace, project, user_id, issue_count):
# Bulk issue
bulk_issue_assignees = []
for issue in issues:
for assignee in random.sample(
list(assignees), random.randint(0, len(assignees) - 1)
):
for assignee in random.sample(list(assignees), random.randint(0, len(assignees) - 1)):
bulk_issue_assignees.append(
IssueAssignee(
issue_id=issue,
@@ -361,9 +336,7 @@ def create_issue_assignees(workspace, project, user_id, issue_count):
)
# Issue assignees
IssueAssignee.objects.bulk_create(
bulk_issue_assignees, batch_size=1000, ignore_conflicts=True
)
IssueAssignee.objects.bulk_create(bulk_issue_assignees, batch_size=1000, ignore_conflicts=True)
def create_issue_labels(workspace, project, user_id, issue_count):
@@ -378,16 +351,10 @@ def create_issue_labels(workspace, project, user_id, issue_count):
bulk_issue_labels = []
for issue in issues:
for label in random.sample(list(labels), random.randint(0, len(labels) - 1)):
bulk_issue_labels.append(
IssueLabel(
issue_id=issue, label_id=label, project=project, workspace=workspace
)
)
bulk_issue_labels.append(IssueLabel(issue_id=issue, label_id=label, project=project, workspace=workspace))
# Issue assignees
IssueLabel.objects.bulk_create(
bulk_issue_labels, batch_size=1000, ignore_conflicts=True
)
IssueLabel.objects.bulk_create(bulk_issue_labels, batch_size=1000, ignore_conflicts=True)
def create_cycle_issues(workspace, project, user_id, issue_count):
@@ -402,16 +369,10 @@ def create_cycle_issues(workspace, project, user_id, issue_count):
bulk_cycle_issues = []
for issue in issues:
cycle = cycles[random.randint(0, len(cycles) - 1)]
bulk_cycle_issues.append(
CycleIssue(
cycle_id=cycle, issue_id=issue, project=project, workspace=workspace
)
)
bulk_cycle_issues.append(CycleIssue(cycle_id=cycle, issue_id=issue, project=project, workspace=workspace))
# Issue assignees
CycleIssue.objects.bulk_create(
bulk_cycle_issues, batch_size=1000, ignore_conflicts=True
)
CycleIssue.objects.bulk_create(bulk_cycle_issues, batch_size=1000, ignore_conflicts=True)
def create_module_issues(workspace, project, user_id, issue_count):
@@ -426,15 +387,9 @@ def create_module_issues(workspace, project, user_id, issue_count):
bulk_module_issues = []
for issue in issues:
module = modules[random.randint(0, len(modules) - 1)]
bulk_module_issues.append(
ModuleIssue(
module_id=module, issue_id=issue, project=project, workspace=workspace
)
)
bulk_module_issues.append(ModuleIssue(module_id=module, issue_id=issue, project=project, workspace=workspace))
# Issue assignees
ModuleIssue.objects.bulk_create(
bulk_module_issues, batch_size=1000, ignore_conflicts=True
)
ModuleIssue.objects.bulk_create(bulk_module_issues, batch_size=1000, ignore_conflicts=True)
@shared_task
@@ -471,52 +426,36 @@ def create_fake_data(slug, email, members, issue_count, cycle_count, module_coun
# create cycles
print("Creating cycles")
_ = create_cycles(
workspace=workspace, project=project, user_id=user_id, cycle_count=cycle_count
)
_ = create_cycles(workspace=workspace, project=project, user_id=user_id, cycle_count=cycle_count)
print("Done creating cycles")
# create modules
print("Creating modules")
_ = create_modules(
workspace=workspace, project=project, user_id=user_id, module_count=module_count
)
_ = create_modules(workspace=workspace, project=project, user_id=user_id, module_count=module_count)
print("Done creating modules")
print("Creating issues")
create_issues(
workspace=workspace, project=project, user_id=user_id, issue_count=issue_count
)
create_issues(workspace=workspace, project=project, user_id=user_id, issue_count=issue_count)
print("Done creating issues")
print("Creating parent and sub issues")
create_issue_parent(
workspace=workspace, project=project, user_id=user_id, issue_count=issue_count
)
create_issue_parent(workspace=workspace, project=project, user_id=user_id, issue_count=issue_count)
print("Done creating parent and sub issues")
print("Creating issue assignees")
create_issue_assignees(
workspace=workspace, project=project, user_id=user_id, issue_count=issue_count
)
create_issue_assignees(workspace=workspace, project=project, user_id=user_id, issue_count=issue_count)
print("Done creating issue assignees")
print("Creating issue labels")
create_issue_labels(
workspace=workspace, project=project, user_id=user_id, issue_count=issue_count
)
create_issue_labels(workspace=workspace, project=project, user_id=user_id, issue_count=issue_count)
print("Done creating issue labels")
print("Creating cycle issues")
create_cycle_issues(
workspace=workspace, project=project, user_id=user_id, issue_count=issue_count
)
create_cycle_issues(workspace=workspace, project=project, user_id=user_id, issue_count=issue_count)
print("Done creating cycle issues")
print("Creating module issues")
create_module_issues(
workspace=workspace, project=project, user_id=user_id, issue_count=issue_count
)
create_module_issues(workspace=workspace, project=project, user_id=user_id, issue_count=issue_count)
print("Done creating module issues")
return
@@ -35,36 +35,37 @@ def stack_email_notification():
email_notifications = EmailNotificationLog.objects.filter(processed_at__isnull=True).order_by("receiver").values()
# Group by receiver_id AND entity_name to handle different entity types separately
receivers_entities = list(set([
(str(notification.get("receiver_id")), notification.get("entity_name"))
for notification in email_notifications
]))
receivers_entities = list(
set(
[
(str(notification.get("receiver_id")), notification.get("entity_name"))
for notification in email_notifications
]
)
)
processed_notifications = []
for receiver_id, entity_name in receivers_entities:
# Get notifications for this specific receiver AND entity_name combination
receiver_notifications = [
notification for notification in email_notifications
if str(notification.get("receiver_id")) == receiver_id
and notification.get("entity_name") == entity_name
notification
for notification in email_notifications
if str(notification.get("receiver_id")) == receiver_id and notification.get("entity_name") == entity_name
]
# Create payload for this entity type only
payload = {}
entity_notification_ids = {}
for receiver_notification in receiver_notifications:
entity_identifier = receiver_notification.get("entity_identifier")
payload.setdefault(receiver_notification.get("entity_identifier"), {}).setdefault(
str(receiver_notification.get("triggered_by_id")), []
).append(receiver_notification.get("data"))
# Track processed notifications and IDs
entity_notification_ids.setdefault(entity_identifier, []).append(
receiver_notification.get("id")
)
entity_notification_ids.setdefault(entity_identifier, []).append(receiver_notification.get("id"))
# Track processed notifications for this entity
processed_notifications.append(receiver_notification.get("id"))
@@ -98,6 +99,7 @@ def stack_email_notification():
# Update the email notification log
EmailNotificationLog.objects.filter(pk__in=processed_notifications).update(processed_at=timezone.now())
def create_payload(notification_data, entity_name):
# return format {"actor_id": { "key": { "old_value": [], "new_value": [] } }}
data = {}
@@ -366,8 +368,6 @@ def send_workspace_level_email_notification(
logging.getLogger("plane.worker").warning(f"Entity not found: {entity_id} for entity name: {entity_name}")
return
actors_involved = []
template_data = []
@@ -458,4 +458,4 @@ def send_email(subject, text_content, receiver, html_content, email_notification
msg.send()
logging.getLogger("plane.worker").info("Email Sent Successfully")
EmailNotificationLog.objects.filter(pk__in=email_notification_ids).update(sent_at=timezone.now())
EmailNotificationLog.objects.filter(pk__in=email_notification_ids).update(sent_at=timezone.now())
@@ -66,11 +66,8 @@ def share_page_notification(page_id, user_id, newly_shared_user_ids, slug):
"page_url": page_url,
"workspace": workspace,
"page_name": page.name,
"page_description": page.description_stripped
or "No description available",
"shared_by_name": f"{user.first_name} {user.last_name}".strip()
or user.display_name
or user.email,
"page_description": page.description_stripped or "No description available",
"shared_by_name": f"{user.first_name} {user.last_name}".strip() or user.display_name or user.email,
"shared_to_name": (
f"{newly_shared_user.first_name} {newly_shared_user.last_name}".strip() # noqa: E501
or newly_shared_user.display_name
@@ -82,9 +79,7 @@ def share_page_notification(page_id, user_id, newly_shared_user_ids, slug):
# Create email subject and content
subject = f"{context['shared_by_name']} shared a page with you"
html_content = render_to_string(
"emails/notifications/share_page.html", context
)
html_content = render_to_string("emails/notifications/share_page.html", context)
text_content = strip_tags(html_content)
# Configure email connection
+2 -5
View File
@@ -71,8 +71,7 @@ def service_importer(service, importer_id):
email__in=[
user.get("email").strip().lower()
for user in users
if user.get("import", False) == "invite"
or user.get("import", False) == "map"
if user.get("import", False) == "invite" or user.get("import", False) == "map"
]
)
@@ -144,9 +143,7 @@ def service_importer(service, importer_id):
GithubRepository.objects.filter(project_id=importer.project_id).delete()
# Create a Label for github
label = Label.objects.filter(
name="GitHub", project_id=importer.project_id
).first()
label = Label.objects.filter(name="GitHub", project_id=importer.project_id).first()
if label is None:
label = Label.objects.create(
+60 -192
View File
@@ -39,6 +39,7 @@ from plane.utils.issue_relation_mapper import get_inverse_relation
from plane.utils.uuid import is_valid_uuid
from plane.bgtasks.notification_task import process_workitem_notifications
def extract_ids(data: dict | None, primary_key: str, fallback_key: str) -> set[str]:
if not data:
return set()
@@ -86,14 +87,8 @@ def track_description(
issue_activities,
epoch,
):
if current_instance.get("description_html") != requested_data.get(
"description_html"
):
last_activity = (
IssueActivity.objects.filter(issue_id=issue_id)
.order_by("-created_at")
.first()
)
if current_instance.get("description_html") != requested_data.get("description_html"):
last_activity = IssueActivity.objects.filter(issue_id=issue_id).order_by("-created_at").first()
if (
last_activity is not None
and last_activity.field == "description"
@@ -150,12 +145,8 @@ def track_parent(
issue_activities,
epoch,
):
current_parent_id = current_instance.get("parent_id") or current_instance.get(
"parent"
)
requested_parent_id = requested_data.get("parent_id") or requested_data.get(
"parent"
)
current_parent_id = current_instance.get("parent_id") or current_instance.get("parent")
requested_parent_id = requested_data.get("parent_id") or requested_data.get("parent")
# Validate UUIDs before database queries
if current_parent_id is not None and not is_valid_uuid(current_parent_id):
@@ -164,16 +155,8 @@ def track_parent(
return
if current_parent_id != requested_parent_id:
old_parent = (
Issue.objects.filter(pk=current_parent_id).first()
if current_parent_id is not None
else None
)
new_parent = (
Issue.objects.filter(pk=requested_parent_id).first()
if requested_parent_id is not None
else None
)
old_parent = Issue.objects.filter(pk=current_parent_id).first() if current_parent_id is not None else None
new_parent = Issue.objects.filter(pk=requested_parent_id).first() if requested_parent_id is not None else None
issue_activities.append(
IssueActivity(
@@ -181,14 +164,10 @@ def track_parent(
actor_id=actor_id,
verb="updated",
old_value=(
f"{old_parent.project.identifier}-{old_parent.sequence_id}"
if old_parent is not None
else ""
f"{old_parent.project.identifier}-{old_parent.sequence_id}" if old_parent is not None else ""
),
new_value=(
f"{new_parent.project.identifier}-{new_parent.sequence_id}"
if new_parent is not None
else ""
f"{new_parent.project.identifier}-{new_parent.sequence_id}" if new_parent is not None else ""
),
field="parent",
project_id=project_id,
@@ -249,12 +228,8 @@ def track_state(
requested_state_id = None
if current_state_id != requested_state_id:
new_state = State.objects.filter(
pk=requested_state_id, project_id=project_id
).first()
old_state = State.objects.filter(
pk=current_state_id, project_id=project_id
).first()
new_state = State.objects.filter(pk=requested_state_id, project_id=project_id).first()
old_state = State.objects.filter(pk=current_state_id, project_id=project_id).first()
issue_activities.append(
IssueActivity(
@@ -292,15 +267,9 @@ def track_target_date(
actor_id=actor_id,
verb="updated",
old_value=(
current_instance.get("target_date")
if current_instance.get("target_date") is not None
else ""
),
new_value=(
requested_data.get("target_date")
if requested_data.get("target_date") is not None
else ""
current_instance.get("target_date") if current_instance.get("target_date") is not None else ""
),
new_value=(requested_data.get("target_date") if requested_data.get("target_date") is not None else ""),
field="target_date",
project_id=project_id,
workspace_id=workspace_id,
@@ -328,15 +297,9 @@ def track_start_date(
actor_id=actor_id,
verb="updated",
old_value=(
current_instance.get("start_date")
if current_instance.get("start_date") is not None
else ""
),
new_value=(
requested_data.get("start_date")
if requested_data.get("start_date") is not None
else ""
current_instance.get("start_date") if current_instance.get("start_date") is not None else ""
),
new_value=(requested_data.get("start_date") if requested_data.get("start_date") is not None else ""),
field="start_date",
project_id=project_id,
workspace_id=workspace_id,
@@ -465,9 +428,7 @@ def track_assignees(
)
# Create assignees subscribers to the issue and ignore if already
IssueSubscriber.objects.bulk_create(
bulk_subscribers, batch_size=10, ignore_conflicts=True
)
IssueSubscriber.objects.bulk_create(bulk_subscribers, batch_size=10, ignore_conflicts=True)
for dropped_assignee in dropped_assginees:
# validate uuids
@@ -504,16 +465,12 @@ def track_estimate_points(
):
if current_instance.get("estimate_point") != requested_data.get("estimate_point"):
old_estimate = (
EstimatePoint.objects.filter(
pk=current_instance.get("estimate_point")
).first()
EstimatePoint.objects.filter(pk=current_instance.get("estimate_point")).first()
if current_instance.get("estimate_point") is not None
else None
)
new_estimate = (
EstimatePoint.objects.filter(
pk=requested_data.get("estimate_point")
).first()
EstimatePoint.objects.filter(pk=requested_data.get("estimate_point")).first()
if requested_data.get("estimate_point") is not None
else None
)
@@ -535,9 +492,7 @@ def track_estimate_points(
else None
),
new_identifier=(
requested_data.get("estimate_point")
if requested_data.get("estimate_point") is not None
else None
requested_data.get("estimate_point") if requested_data.get("estimate_point") is not None else None
),
old_value=old_estimate.value if old_estimate else None,
new_value=new_estimate.value if new_estimate else None,
@@ -610,9 +565,7 @@ def track_closed_to(
epoch,
):
if requested_data.get("closed_to") is not None:
updated_state = State.objects.get(
pk=requested_data.get("closed_to"), project_id=project_id
)
updated_state = State.objects.get(pk=requested_data.get("closed_to"), project_id=project_id)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
@@ -646,12 +599,8 @@ def track_type(
if new_type_id != old_type_id:
verb = "updated" if new_type_id else "deleted"
old_type = (
IssueType.objects.filter(pk=old_type_id).first() if old_type_id else None
)
new_type = (
IssueType.objects.filter(pk=new_type_id).first() if new_type_id else None
)
old_type = IssueType.objects.filter(pk=old_type_id).first() if old_type_id else None
new_type = IssueType.objects.filter(pk=new_type_id).first() if new_type_id else None
issue_activities.append(
IssueActivity(
@@ -741,9 +690,7 @@ def update_issue_activity(
}
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
for key in requested_data:
func = ISSUE_ACTIVITY_MAPPER.get(key)
@@ -795,9 +742,7 @@ def create_comment_activity(
epoch,
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
issue_activities.append(
IssueActivity(
@@ -827,9 +772,7 @@ def update_comment_activity(
epoch,
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
if current_instance.get("comment_html") != requested_data.get("comment_html"):
issue_activities.append(
@@ -888,21 +831,15 @@ def create_cycle_issue_activity(
epoch,
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
# Updated Records:
updated_records = current_instance.get("updated_cycle_issues", [])
created_records = json.loads(current_instance.get("created_cycle_issues", []))
for updated_record in updated_records:
old_cycle = Cycle.objects.filter(
pk=updated_record.get("old_cycle_id", None)
).first()
new_cycle = Cycle.objects.filter(
pk=updated_record.get("new_cycle_id", None)
).first()
old_cycle = Cycle.objects.filter(pk=updated_record.get("old_cycle_id", None)).first()
new_cycle = Cycle.objects.filter(pk=updated_record.get("new_cycle_id", None)).first()
issue = Issue.objects.filter(pk=updated_record.get("issue_id")).first()
if issue:
issue.updated_at = timezone.now()
@@ -927,12 +864,8 @@ def create_cycle_issue_activity(
)
for created_record in created_records:
cycle = Cycle.objects.filter(
pk=created_record.get("fields").get("cycle")
).first()
issue = Issue.objects.filter(
pk=created_record.get("fields").get("issue")
).first()
cycle = Cycle.objects.filter(pk=created_record.get("fields").get("cycle")).first()
issue = Issue.objects.filter(pk=created_record.get("fields").get("issue")).first()
if issue:
issue.updated_at = timezone.now()
issue.save(update_fields=["updated_at"])
@@ -965,9 +898,7 @@ def delete_cycle_issue_activity(
epoch,
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
cycle_id = requested_data.get("cycle_id", "")
cycle_name = requested_data.get("cycle_name", "")
@@ -1039,9 +970,7 @@ def delete_module_issue_activity(
epoch,
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
module_name = current_instance.get("module_name")
current_issue = Issue.objects.filter(pk=issue_id).first()
if current_issue:
@@ -1058,11 +987,7 @@ def delete_module_issue_activity(
project_id=project_id,
workspace_id=workspace_id,
comment=f"removed this issue from {module_name}",
old_identifier=(
requested_data.get("module_id")
if requested_data.get("module_id") is not None
else None
),
old_identifier=(requested_data.get("module_id") if requested_data.get("module_id") is not None else None),
epoch=epoch,
)
)
@@ -1112,9 +1037,7 @@ def update_milestone_issue_activity(
epoch,
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
milestone = Milestone.objects.filter(pk=requested_data.get("milestone_id")).first()
issue = Issue.objects.filter(pk=issue_id).first()
if issue:
@@ -1149,9 +1072,7 @@ def delete_milestone_issue_activity(
epoch,
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
milestone_name = current_instance.get("milestone_name")
current_issue = Issue.objects.filter(pk=issue_id).first()
if current_issue:
@@ -1169,9 +1090,7 @@ def delete_milestone_issue_activity(
workspace_id=workspace_id,
comment=f"removed from milestone {milestone_name}",
old_identifier=(
requested_data.get("milestone_id")
if requested_data.get("milestone_id") is not None
else None
requested_data.get("milestone_id") if requested_data.get("milestone_id") is not None else None
),
epoch=epoch,
)
@@ -1189,9 +1108,7 @@ def create_link_activity(
epoch,
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
issue_activities.append(
IssueActivity(
@@ -1220,9 +1137,7 @@ def update_link_activity(
epoch,
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
if current_instance.get("url") != requested_data.get("url"):
issue_activities.append(
@@ -1253,9 +1168,7 @@ def delete_link_activity(
issue_activities,
epoch,
):
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
issue_activities.append(
IssueActivity(
@@ -1284,9 +1197,7 @@ def create_attachment_activity(
epoch,
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
issue_activities.append(
IssueActivity(
@@ -1378,9 +1289,7 @@ def delete_issue_reaction_activity(
issue_activities,
epoch,
):
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
if current_instance and current_instance.get("reaction") is not None:
issue_activities.append(
IssueActivity(
@@ -1422,11 +1331,7 @@ def create_comment_reaction_activity(
.first()
)
comment = IssueComment.objects.get(pk=comment_id, project_id=project_id)
if (
comment is not None
and comment_reaction_id is not None
and comment_id is not None
):
if comment is not None and comment_reaction_id is not None and comment_id is not None:
issue_activities.append(
IssueActivity(
issue_id=comment.issue_id,
@@ -1455,14 +1360,10 @@ def delete_comment_reaction_activity(
issue_activities,
epoch,
):
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
if current_instance and current_instance.get("reaction") is not None:
issue_id = (
IssueComment.objects.filter(
pk=current_instance.get("comment_id"), project_id=project_id
)
IssueComment.objects.filter(pk=current_instance.get("comment_id"), project_id=project_id)
.values_list("issue_id", flat=True)
.first()
)
@@ -1525,9 +1426,7 @@ def delete_issue_vote_activity(
issue_activities,
epoch,
):
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
if current_instance and current_instance.get("vote") is not None:
issue_activities.append(
IssueActivity(
@@ -1558,9 +1457,7 @@ def create_issue_relation_activity(
epoch,
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
if current_instance is None and requested_data.get("issues") is not None:
for related_issue in requested_data.get("issues"):
issue = Issue.objects.get(pk=related_issue)
@@ -1609,9 +1506,7 @@ def delete_issue_relation_activity(
epoch,
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
issue = Issue.objects.get(pk=requested_data.get("related_issue"))
issue_activities.append(
IssueActivity(
@@ -1689,13 +1584,8 @@ def update_draft_issue_activity(
epoch,
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
if (
requested_data.get("is_draft") is not None
and requested_data.get("is_draft") is False
):
current_instance = json.loads(current_instance) if current_instance is not None else None
if requested_data.get("is_draft") is not None and requested_data.get("is_draft") is False:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
@@ -1756,9 +1646,7 @@ def create_intake_activity(
epoch,
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
status_dict = {
-2: "Pending",
-1: "Rejected",
@@ -1833,11 +1721,7 @@ def create_customer_activity(
):
requested_data = json.loads(requested_data) if requested_data is not None else None
field = (
"customer_request"
if requested_data.get("customer_request_id") is not None
else "customer"
)
field = "customer_request" if requested_data.get("customer_request_id") is not None else "customer"
issue_activities.append(
IssueActivity(
@@ -1868,11 +1752,7 @@ def delete_customer_activity(
):
requested_data = json.loads(requested_data) if requested_data is not None else None
field = (
"customer_request"
if requested_data.get("customer_request_id") is not None
else "customer"
)
field = "customer_request" if requested_data.get("customer_request_id") is not None else "customer"
issue_activities.append(
IssueActivity(
@@ -2059,11 +1939,7 @@ def issue_activity(
workspace_id = project.workspace_id
if issue_id is not None:
issue = (
Issue.objects.filter(pk=issue_id)
.filter(Q(type__isnull=True) | Q(type__is_epic=False))
.first()
)
issue = Issue.objects.filter(pk=issue_id).filter(Q(type__isnull=True) | Q(type__is_epic=False)).first()
if origin and issue:
ri = redis_instance()
# set the request origin in redis
@@ -2133,26 +2009,18 @@ def issue_activity(
if len(issue_activities_created):
for activity in issue_activities_created:
webhook_activity.delay(
event=(
"issue_comment"
if activity.field == "comment"
else "intake_issue" if intake else "issue"
),
event=("issue_comment" if activity.field == "comment" else "intake_issue" if intake else "issue"),
event_id=(
activity.issue_comment_id
if activity.field == "comment"
else intake if intake else activity.issue_id
else intake
if intake
else activity.issue_id
),
verb=activity.verb,
field=(
"description" if activity.field == "comment" else activity.field
),
old_value=(
activity.old_value if activity.old_value != "" else None
),
new_value=(
activity.new_value if activity.new_value != "" else None
),
field=("description" if activity.field == "comment" else activity.field),
old_value=(activity.old_value if activity.old_value != "" else None),
new_value=(activity.new_value if activity.new_value != "" else None),
actor_id=activity.actor_id,
current_site=origin,
slug=activity.workspace.slug,
+5 -8
View File
@@ -18,7 +18,7 @@ def process_workitem_notifications(
requested_data=None,
current_instance=None,
subscriber=False,
notification_type=""
notification_type="",
):
"""
Process notifications for issue activities.
@@ -39,13 +39,13 @@ def process_workitem_notifications(
requested_data=requested_data,
current_instance=current_instance,
subscriber=subscriber,
notification_type=notification_type
notification_type=notification_type,
)
# Process notifications
handler = WorkItemNotificationHandler(context)
payload = handler.process()
return {
"success": True,
"in_app_count": len(payload.in_app_notifications),
@@ -53,7 +53,4 @@ def process_workitem_notifications(
}
except Exception as e:
log_exception(e)
return {
"success": False,
"error": str(e)
}
return {"success": False, "error": str(e)}
@@ -1,5 +1,4 @@
# Python imports
import json
import logging
# Django imports
@@ -19,9 +19,7 @@ class CredentialsUpdate(DatabaseMigration):
def update_credentials(self):
print("Updating credentials...")
total_count = self._execute_with_error_handling(
"SELECT COUNT(*) as count FROM silo.credentials"
)[0]["count"]
total_count = self._execute_with_error_handling("SELECT COUNT(*) as count FROM silo.credentials")[0]["count"]
total_batches = (total_count + self.batch_size - 1) // self.batch_size
records_processed = 0
@@ -40,9 +40,7 @@ class DatabaseMigration:
def migrate_credentials(self):
print("Migrating credentials...")
total_count = self._execute_with_error_handling(
"SELECT COUNT(*) as count FROM silo.credentials"
)[0]["count"]
total_count = self._execute_with_error_handling("SELECT COUNT(*) as count FROM silo.credentials")[0]["count"]
total_batches = (total_count + self.batch_size - 1) // self.batch_size
records_processed = 0
@@ -77,9 +75,7 @@ class DatabaseMigration:
)
for cred in current_batch
]
WorkspaceCredential.objects.bulk_create(
credentials, ignore_conflicts=True
)
WorkspaceCredential.objects.bulk_create(credentials, ignore_conflicts=True)
records_processed += len(current_batch)
print(
@@ -94,9 +90,9 @@ class DatabaseMigration:
def migrate_workspace_connections(self):
print("Migrating workspace connections...")
total_count = self._execute_with_error_handling(
"SELECT COUNT(*) as count FROM silo.workspace_connections"
)[0]["count"]
total_count = self._execute_with_error_handling("SELECT COUNT(*) as count FROM silo.workspace_connections")[0][
"count"
]
total_batches = (total_count + self.batch_size - 1) // self.batch_size
records_processed = 0
@@ -132,9 +128,7 @@ class DatabaseMigration:
)
for conn in current_batch
]
WorkspaceConnection.objects.bulk_create(
connections, ignore_conflicts=True
)
WorkspaceConnection.objects.bulk_create(connections, ignore_conflicts=True)
records_processed += len(current_batch)
print(
@@ -149,9 +143,9 @@ class DatabaseMigration:
def migrate_entity_connections(self):
print("Migrating entity connections...")
total_count = self._execute_with_error_handling(
"SELECT COUNT(*) as count FROM silo.entity_connections"
)[0]["count"]
total_count = self._execute_with_error_handling("SELECT COUNT(*) as count FROM silo.entity_connections")[0][
"count"
]
total_batches = (total_count + self.batch_size - 1) // self.batch_size
records_processed = 0
@@ -176,11 +170,7 @@ class DatabaseMigration:
project_id=entity["project_id"],
workspace_connection_id=entity["workspace_connection_id"],
type=entity["connection_type"],
entity_type=(
entity["entity_data"]["type"]
if "type" in entity["entity_data"]
else None
),
entity_type=(entity["entity_data"]["type"] if "type" in entity["entity_data"] else None),
entity_id=entity["entity_id"],
entity_slug=entity["entity_slug"],
entity_data=entity["entity_data"],
@@ -190,9 +180,7 @@ class DatabaseMigration:
)
for entity in current_batch
]
WorkspaceEntityConnection.objects.bulk_create(
entities, ignore_conflicts=True
)
WorkspaceEntityConnection.objects.bulk_create(entities, ignore_conflicts=True)
records_processed += len(current_batch)
print(
@@ -207,9 +195,7 @@ class DatabaseMigration:
def migrate_jobs(self):
print("Migrating jobs...")
total_count = self._execute_with_error_handling(
"SELECT COUNT(*) as count FROM silo.job_configs"
)[0]["count"]
total_count = self._execute_with_error_handling("SELECT COUNT(*) as count FROM silo.job_configs")[0]["count"]
report_ids = []
total_batches = (total_count + self.batch_size - 1) // self.batch_size
@@ -286,11 +272,7 @@ class DatabaseMigration:
project_id=job["project_id"],
workspace_id=job["workspace_id"],
initiator_id=job["initiator_id"],
report_id=(
report_ids[i]
if i < len(report_ids)
else str(uuid.uuid4())
),
report_id=(report_ids[i] if i < len(report_ids) else str(uuid.uuid4())),
status=job["status"],
created_at=job["created_at"],
updated_at=job["updated_at"],
@@ -304,8 +286,7 @@ class DatabaseMigration:
records_processed += len(current_batch)
print(
f"Processed jobs batch {batch_num + 1}/{total_batches} "
f"({records_processed}/{total_count} records)"
f"Processed jobs batch {batch_num + 1}/{total_batches} ({records_processed}/{total_count} records)"
)
except Exception as e:
@@ -14,11 +14,10 @@ from typing import Optional
from plane.db.models import IssueLink, ModuleLink
from plane.utils.exception_logger import log_exception
from plane.ee.models import InitiativeLink, ProjectLink
from typing import Literal, Type, Union
from typing import Literal
# Django imports
from django.apps import apps
logger = logging.getLogger("plane.worker")
+52 -48
View File
@@ -107,56 +107,60 @@ def create_project_and_member(workspace: Workspace, bot_user: User) -> Dict[int,
)
# Create project members
ProjectMember.objects.bulk_create([
ProjectMember(
project=project,
member_id=workspace_member["member_id"],
role=workspace_member["role"],
workspace_id=workspace.id,
created_by_id=bot_user.id,
)
for workspace_member in workspace_members
])
ProjectMember.objects.bulk_create(
[
ProjectMember(
project=project,
member_id=workspace_member["member_id"],
role=workspace_member["role"],
workspace_id=workspace.id,
created_by_id=bot_user.id,
)
for workspace_member in workspace_members
]
)
# Create issue user properties
IssueUserProperty.objects.bulk_create([
IssueUserProperty(
project=project,
user_id=workspace_member["member_id"],
workspace_id=workspace.id,
display_filters={
"layout": "list",
"calendar": {"layout": "month", "show_weekends": False},
"group_by": "state",
"order_by": "sort_order",
"sub_issue": True,
"sub_group_by": None,
"show_empty_groups": True,
},
display_properties={
"key": True,
"link": True,
"cycle": False,
"state": True,
"labels": False,
"modules": False,
"assignee": True,
"due_date": False,
"estimate": True,
"priority": True,
"created_on": True,
"issue_type": True,
"start_date": False,
"updated_on": True,
"customer_count": True,
"sub_issue_count": False,
"attachment_count": False,
"customer_request_count": True,
},
created_by_id=bot_user.id,
)
for workspace_member in workspace_members
])
IssueUserProperty.objects.bulk_create(
[
IssueUserProperty(
project=project,
user_id=workspace_member["member_id"],
workspace_id=workspace.id,
display_filters={
"layout": "list",
"calendar": {"layout": "month", "show_weekends": False},
"group_by": "state",
"order_by": "sort_order",
"sub_issue": True,
"sub_group_by": None,
"show_empty_groups": True,
},
display_properties={
"key": True,
"link": True,
"cycle": False,
"state": True,
"labels": False,
"modules": False,
"assignee": True,
"due_date": False,
"estimate": True,
"priority": True,
"created_on": True,
"issue_type": True,
"start_date": False,
"updated_on": True,
"customer_count": True,
"sub_issue_count": False,
"attachment_count": False,
"customer_request_count": True,
},
created_by_id=bot_user.id,
)
for workspace_member in workspace_members
]
)
# update map
projects_map[project_id] = project.id
logger.info(f"Task: workspace_seed_task -> Project {project_id} created")
@@ -27,15 +27,13 @@ def backfill_issue_type_task(projects):
# Update the issue type for all existing issues
issue_types = {
str(issue_type["project_id"]): str(issue_type["id"])
for issue_type in IssueType.objects.filter(
project_id__in=[project["id"] for project in projects]
).values("id", "project_id")
for issue_type in IssueType.objects.filter(project_id__in=[project["id"] for project in projects]).values(
"id", "project_id"
)
}
# Update the issue type for all existing issues
bulk_issues = []
for issue in Issue.objects.filter(
project_id__in=[project["id"] for project in projects]
):
for issue in Issue.objects.filter(project_id__in=[project["id"] for project in projects]):
issue.type_id = issue_types[str(issue.project_id)]
bulk_issues.append(issue)
@@ -49,9 +49,7 @@ class Command(BaseCommand):
if project_id == "":
raise CommandError("Project ID is required")
project = Project.objects.filter(
id=project_id, workspace__slug=workspace_slug
).first()
project = Project.objects.filter(id=project_id, workspace__slug=workspace_slug).first()
if not project:
raise CommandError("Project does not exists")
@@ -87,9 +85,7 @@ class Command(BaseCommand):
)
# Get the maximum sequence_id
last_id = IssueSequence.objects.filter(project=project).aggregate(
largest=Max("sequence")
)["largest"]
last_id = IssueSequence.objects.filter(project=project).aggregate(largest=Max("sequence"))["largest"]
last_id = 1 if last_id is None else last_id + 1
@@ -98,9 +94,7 @@ class Command(BaseCommand):
project=project, state_id=states[random.randint(0, len(states) - 1)]
).aggregate(largest=Max("sort_order"))["largest"]
largest_sort_order = (
65535 if largest_sort_order is None else largest_sort_order + 10000
)
largest_sort_order = 65535 if largest_sort_order is None else largest_sort_order + 10000
fake = Faker()
Faker.seed(0)
issues = []
@@ -113,9 +107,7 @@ class Command(BaseCommand):
workspace=workspace,
name=sentence[:254],
sequence_id=last_id,
priority=["urgent", "high", "medium", "low", "none"][
random.randint(0, 4)
],
priority=["urgent", "high", "medium", "low", "none"][random.randint(0, 4)],
created_by_id=project.created_by_id,
)
)
@@ -123,9 +115,7 @@ class Command(BaseCommand):
largest_sort_order = largest_sort_order + random.randint(0, 1000)
last_id = last_id + 1
issues = Issue.objects.bulk_create(
issues, ignore_conflicts=True, batch_size=1000
)
issues = Issue.objects.bulk_create(issues, ignore_conflicts=True, batch_size=1000)
# Sequences
IssueSequence.objects.bulk_create(
[
@@ -140,12 +130,7 @@ class Command(BaseCommand):
batch_size=100,
)
CycleIssue.objects.bulk_create(
[
CycleIssue(
issue=issue, cycle=cycle, project=project, workspace=workspace
)
for issue in issues
],
[CycleIssue(issue=issue, cycle=cycle, project=project, workspace=workspace) for issue in issues],
batch_size=100,
)
@@ -23,9 +23,7 @@ class Command(BaseCommand):
creator = input("Your email: ")
if creator == "" or not User.objects.filter(email=creator).exists():
raise CommandError(
"User email is required and should be existing in Database"
)
raise CommandError("User email is required and should be existing in Database")
user = User.objects.get(email=creator)
@@ -37,9 +35,7 @@ class Command(BaseCommand):
module_count = int(input("Number of modules to be created: "))
# Create workspace
workspace = Workspace.objects.create(
slug=workspace_slug, name=workspace_name, owner=user
)
workspace = Workspace.objects.create(slug=workspace_slug, name=workspace_name, owner=user)
# Create workspace member
WorkspaceMember.objects.create(workspace=workspace, role=20, member=user)
@@ -53,38 +53,26 @@ class Command(BaseCommand):
except Workspace.DoesNotExist:
raise CommandError(f"Workspace '{workspace_slug}' not found")
self.stdout.write(
self.style.SUCCESS(
f"Found workspace: {workspace.name} ({workspace.slug})"
)
)
self.stdout.write(self.style.SUCCESS(f"Found workspace: {workspace.name} ({workspace.slug})"))
# Get projects based on whether project_id is provided
if project_id:
# Process only the specified project
try:
projects = Project.objects.filter(
workspace=workspace, id=project_id
)
projects = Project.objects.filter(workspace=workspace, id=project_id)
if not projects.exists():
raise CommandError(
f"Project with ID '{project_id}' not found in workspace '{workspace_slug}'"
)
raise CommandError(f"Project with ID '{project_id}' not found in workspace '{workspace_slug}'")
self.stdout.write(
f"Processing specific project: {projects.first().name} ({projects.first().identifier})"
)
except Project.DoesNotExist:
raise CommandError(
f"Project with ID '{project_id}' not found in workspace '{workspace_slug}'"
)
raise CommandError(f"Project with ID '{project_id}' not found in workspace '{workspace_slug}'")
else:
# Get all projects in the workspace
projects = Project.objects.filter(workspace=workspace).order_by("name")
if not projects.exists():
self.stdout.write(
self.style.WARNING("No projects found in this workspace!")
)
self.stdout.write(self.style.WARNING("No projects found in this workspace!"))
return
self.stdout.write(f"Found {projects.count()} projects in workspace")
@@ -95,16 +83,10 @@ class Command(BaseCommand):
# Loop through each project
for project in projects:
self.stdout.write("\n" + "=" * 60)
self.stdout.write(
self.style.SUCCESS(
f"Processing project: {project.name} ({project.identifier})"
)
)
self.stdout.write(self.style.SUCCESS(f"Processing project: {project.name} ({project.identifier})"))
# Process this project
project_updated_count = self.process_project(
project, dry_run, auto_confirm
)
project_updated_count = self.process_project(project, dry_run, auto_confirm)
if project_updated_count > 0:
total_projects_processed += 1
@@ -132,9 +114,7 @@ class Command(BaseCommand):
)
if not duplicate_sequences:
self.stdout.write(
self.style.SUCCESS(" ✓ No duplicate sequences found in this project!")
)
self.stdout.write(self.style.SUCCESS(" ✓ No duplicate sequences found in this project!"))
return 0
self.stdout.write(
@@ -148,9 +128,7 @@ class Command(BaseCommand):
total_duplicates = 0
for sequence_id in duplicate_sequences:
issues = Issue.objects.filter(
project=project, sequence_id=sequence_id
).order_by(
issues = Issue.objects.filter(project=project, sequence_id=sequence_id).order_by(
"created_at"
) # Order by creation time, keep the oldest
@@ -159,20 +137,12 @@ class Command(BaseCommand):
self.stdout.write(f" Sequence {sequence_id}: {len(issues)} issues")
for issue in issues:
self.stdout.write(
f" - {issue.name} (ID: {issue.id}, Created: {issue.created_at})"
)
self.stdout.write(f" - {issue.name} (ID: {issue.id}, Created: {issue.created_at})")
self.stdout.write(
self.style.WARNING(
f" Total issues to be updated in this project: {total_duplicates}"
)
)
self.stdout.write(self.style.WARNING(f" Total issues to be updated in this project: {total_duplicates}"))
if dry_run:
self.stdout.write(
self.style.SUCCESS(" DRY RUN: No changes were made for this project.")
)
self.stdout.write(self.style.SUCCESS(" DRY RUN: No changes were made for this project."))
return 0
# Ask for confirmation unless auto-confirm is enabled
@@ -202,10 +172,7 @@ class Command(BaseCommand):
# Get the current maximum sequence for the project
last_sequence = (
IssueSequence.objects.filter(project=project).aggregate(
largest=Max("sequence")
)["largest"]
or 0
IssueSequence.objects.filter(project=project).aggregate(largest=Max("sequence"))["largest"] or 0
)
# Prepare bulk updates
@@ -214,10 +181,7 @@ class Command(BaseCommand):
new_sequence = last_sequence
# Get all issue sequences for this project for efficient lookup
issue_sequence_map = {
isq.issue_id: isq
for isq in IssueSequence.objects.filter(project=project)
}
issue_sequence_map = {isq.issue_id: isq for isq in IssueSequence.objects.filter(project=project)}
updated_count = 0
@@ -226,9 +190,7 @@ class Command(BaseCommand):
# Keep the first issue (oldest), update the rest
issues_to_update = issues[1:] # Skip the first one
self.stdout.write(
f" Keeping issue '{issues[0].name}' with sequence {sequence_id}"
)
self.stdout.write(f" Keeping issue '{issues[0].name}' with sequence {sequence_id}")
for issue in issues_to_update:
new_sequence += 1
@@ -243,9 +205,7 @@ class Command(BaseCommand):
sequence_obj.sequence = new_sequence
bulk_issue_sequences.append(sequence_obj)
self.stdout.write(
f" Updating '{issue.name}': {old_sequence} -> {new_sequence}"
)
self.stdout.write(f" Updating '{issue.name}': {old_sequence} -> {new_sequence}")
# Perform bulk updates
if bulk_issues:
@@ -254,14 +214,10 @@ class Command(BaseCommand):
if bulk_issue_sequences:
IssueSequence.objects.bulk_update(bulk_issue_sequences, ["sequence"])
self.stdout.write(
f" Updated {len(bulk_issue_sequences)} issue sequences"
)
self.stdout.write(f" Updated {len(bulk_issue_sequences)} issue sequences")
self.stdout.write(
self.style.SUCCESS(
f" ✓ Successfully fixed duplicate sequences! Updated {updated_count} issues."
)
self.style.SUCCESS(f" ✓ Successfully fixed duplicate sequences! Updated {updated_count} issues.")
)
return updated_count
@@ -37,9 +37,7 @@ class Command(BaseCommand):
# Check if status code is 204
if response.status_code == 204:
self.stdout.write(
self.style.SUCCESS("License key verified successfully")
)
self.stdout.write(self.style.SUCCESS("License key verified successfully"))
return
elif response.status_code == 400:
@@ -58,9 +56,7 @@ class Command(BaseCommand):
},
)
response.raise_for_status()
self.stdout.write(
self.style.SUCCESS("Instance created successfully")
)
self.stdout.write(self.style.SUCCESS("Instance created successfully"))
return
else:
@@ -64,11 +64,7 @@ class Command(BaseCommand):
for field in required_fields:
value = getattr(template, field, None)
if (
value is None
or value == ""
or (isinstance(value, list) and len(value) == 0)
):
if value is None or value == "" or (isinstance(value, list) and len(value) == 0):
missing_fields.append(field)
return missing_fields
@@ -99,40 +95,24 @@ class Command(BaseCommand):
return
# Ask user if the template should be marked as is_verified
is_verified_input = (
input("Should the template be marked as is_verified? (y/n): ")
.strip()
.lower()
)
is_verified_input = input("Should the template be marked as is_verified? (y/n): ").strip().lower()
if is_verified_input == "y":
template.is_verified = True
elif is_verified_input == "n":
template.is_verified = False
else:
self.stdout.write(
self.style.ERROR(
"Invalid input for is_verified. Please enter 'y' or 'n'."
)
)
self.stdout.write(self.style.ERROR("Invalid input for is_verified. Please enter 'y' or 'n'."))
return
template.is_published = True
template.save()
self.stdout.write(
self.style.SUCCESS(
f"Template {template_id} published successfully."
)
)
self.stdout.write(self.style.SUCCESS(f"Template {template_id} published successfully."))
elif action == "unpublish":
template.is_published = False
template.save()
self.stdout.write(
self.style.SUCCESS(
f"Template {template_id} unpublished successfully."
)
)
self.stdout.write(self.style.SUCCESS(f"Template {template_id} unpublished successfully."))
except Template.DoesNotExist:
raise CommandError(f"Template with id {template_id} does not exist")
+7 -12
View File
@@ -34,13 +34,11 @@ class PageQuerySet(SoftDeletionQuerySet):
projects__project_projectmember__is_active=True,
)
if check_workspace_feature_flag(
feature_key=FeatureFlag.TEAMSPACES, user_id=user_id, slug=slug
):
if check_workspace_feature_flag(feature_key=FeatureFlag.TEAMSPACES, user_id=user_id, slug=slug):
## Get all team ids where the user is a member
teamspace_ids = TeamspaceMember.objects.filter(
member_id=user_id, workspace__slug=slug
).values_list("team_space_id", flat=True)
teamspace_ids = TeamspaceMember.objects.filter(member_id=user_id, workspace__slug=slug).values_list(
"team_space_id", flat=True
)
member_project_ids = ProjectMember.objects.filter(
member_id=user_id, workspace__slug=slug, is_active=True
@@ -112,7 +110,7 @@ class Page(BaseModel):
external_source = models.CharField(max_length=255, null=True, blank=True)
objects = PageManager()
class Meta:
verbose_name = "Page"
verbose_name_plural = "Pages"
@@ -126,9 +124,7 @@ class Page(BaseModel):
deferred_fields = self.get_deferred_fields()
self._original_name = self.name if "name" not in deferred_fields else None
self._original_description_stripped = (
self.description_stripped
if "description_stripped" not in deferred_fields
else None
self.description_stripped if "description_stripped" not in deferred_fields else None
)
def __str__(self):
@@ -149,8 +145,7 @@ class Page(BaseModel):
return (
self.description_html == "<p></p>"
or self.description_html == '<p class="editor-paragraph-block"></p>'
or self.description_html
== '<p class="editor-paragraph-block"></p><p class="editor-paragraph-block"></p>'
or self.description_html == '<p class="editor-paragraph-block"></p><p class="editor-paragraph-block"></p>'
or not self.description_html
)
-1
View File
@@ -23,7 +23,6 @@ from ..mixins import TimeAuditModel
from plane.utils.color import get_random_color
def get_default_onboarding():
return {
"profile_complete": False,
@@ -48,9 +48,7 @@ def delete_automation_activity(
automation_activities,
epoch,
):
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
automation_activities.append(
AutomationActivity(
automation=automation,
@@ -106,9 +104,7 @@ def update_automation_activity(
TRACKED_FIELDS = ["name", "description", "status", "scope", "is_enabled"]
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
# Track changes for each field present in the requested data
for field_name in requested_data:
@@ -194,9 +190,7 @@ def update_automation_node_activity(
TRACKED_FIELDS = ["name", "node_type", "is_enabled", "handler_name", "config"]
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
for field_name in requested_data:
if field_name in TRACKED_FIELDS:
@@ -287,14 +281,10 @@ def track_automation_edge_field_change(
old_value=current_instance.get(field_name),
new_value=requested_data.get(field_name),
old_identifier=(
current_instance.get(field_name)
if field_name in ["target_node", "source_node"]
else None
current_instance.get(field_name) if field_name in ["target_node", "source_node"] else None
),
new_identifier=(
requested_data.get(field_name)
if field_name in ["target_node", "source_node"]
else None
requested_data.get(field_name) if field_name in ["target_node", "source_node"] else None
),
epoch=epoch,
)
@@ -313,9 +303,7 @@ def update_automation_edge_activity(
):
TRACKED_FIELDS = ["target_node", "source_node", "execution_order"]
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
for field_name in requested_data:
if field_name in TRACKED_FIELDS:
@@ -59,9 +59,7 @@ def process_batched_opensearch_updates():
f"OpenSearch batch processed {chunk_processed} {model_name} instances with registry-powered cascades"
)
except Exception as e:
logger.error(
f"Error processing chunk {chunk_info['chunk_number']} for {model_name}: {e}"
)
logger.error(f"Error processing chunk {chunk_info['chunk_number']} for {model_name}: {e}")
# Continue processing other chunks even if one fails
total_processed += model_processed
@@ -26,6 +26,7 @@ from plane.utils.exception_logger import log_exception
from plane.bgtasks.webhook_task import webhook_activity
from plane.bgtasks.notification_task import process_workitem_notifications
# Track changes in issue labels
def track_labels(
requested_data,
@@ -75,14 +76,10 @@ def track_assignees(
epoch,
):
requested_assignees = (
set([str(asg) for asg in requested_data.get("assignee_ids", [])])
if requested_data is not None
else set()
set([str(asg) for asg in requested_data.get("assignee_ids", [])]) if requested_data is not None else set()
)
current_assignees = (
set([str(asg) for asg in current_instance.get("assignee_ids", [])])
if current_instance is not None
else set()
set([str(asg) for asg in current_instance.get("assignee_ids", [])]) if current_instance is not None else set()
)
added_assignees = requested_assignees - current_assignees
@@ -117,9 +114,7 @@ def track_assignees(
)
# Create assignees subscribers to the issue and ignore if already
IssueSubscriber.objects.bulk_create(
bulk_subscribers, batch_size=10, ignore_conflicts=True
)
IssueSubscriber.objects.bulk_create(bulk_subscribers, batch_size=10, ignore_conflicts=True)
def create_cycle_issue_activity(
@@ -133,9 +128,7 @@ def create_cycle_issue_activity(
epoch,
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
if requested_data.get("cycle_id") and current_instance.get("cycle_id"):
new_cycle = Cycle.objects.filter(pk=requested_data.get("cycle_id")).first()
@@ -190,9 +183,7 @@ def delete_cycle_issue_activity(
) -> List[IssueActivity]:
"""Create a cycle activity when a cycle is removed from an issue"""
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
# If the issue has a cycle, create a cycle activity
if current_instance.get("cycle_id"):
@@ -229,9 +220,7 @@ def update_issue_activity(
ISSUE_ACTIVITY_MAPPER = {"label_ids": track_labels, "assignee_ids": track_assignees}
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
for key in requested_data:
func = ISSUE_ACTIVITY_MAPPER.get(key)
@@ -307,13 +296,7 @@ def bulk_issue_activity(
if len(issue_activities_created):
for activity in issue_activities_created:
webhook_activity.delay(
event=(
"issue_comment"
if activity.field == "comment"
else "inbox_issue"
if inbox
else "issue"
),
event=("issue_comment" if activity.field == "comment" else "inbox_issue" if inbox else "issue"),
event_id=(
activity.issue_comment_id
if activity.field == "comment"
@@ -322,15 +305,9 @@ def bulk_issue_activity(
else activity.issue_id
),
verb=activity.verb,
field=(
"description" if activity.field == "comment" else activity.field
),
old_value=(
activity.old_value if activity.old_value != "" else None
),
new_value=(
activity.new_value if activity.new_value != "" else None
),
field=("description" if activity.field == "comment" else activity.field),
old_value=(activity.old_value if activity.old_value != "" else None),
new_value=(activity.new_value if activity.new_value != "" else None),
actor_id=activity.actor_id,
current_site=origin,
slug=activity.workspace.slug,
@@ -1,5 +1,4 @@
from __future__ import annotations
import json
import pytz
# Python imports
@@ -11,20 +10,11 @@ from django.db.models import Subquery
# Third Party imports
from celery import shared_task
from django.db.models import Count, Q, When, Case, Value, FloatField, Sum, F
from django.db.models.functions import Cast, Concat
from django.db import models, transaction
from django.db.models import Q
# Module imports
from plane.ee.models import CycleSettings, ProjectFeature, AutomatedCycleLog
from plane.db.models import Cycle, Project, Issue, CycleIssue, Workspace
from plane.utils.analytics_plot import burndown_plot
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.webhook_task import model_activity
from plane.ee.bgtasks.entity_issue_state_progress_task import (
entity_issue_state_activity_task,
)
from plane.utils.host import base_host
from plane.db.models import Cycle, Project, Workspace
from plane.db.models import BotTypeEnum, ProjectMember
from plane.utils.exception_logger import log_exception
from plane.utils.cycle_transfer_issues import transfer_cycle_issues
@@ -40,8 +40,7 @@ def entity_issue_state_activity_task(issue_cycle_data, user_id, slug, action):
issue.estimate_point.value
if issue.estimate_point
and issue.estimate_point.estimate
and getattr(issue.estimate_point.estimate, "type", None)
in ["points", "time"]
and getattr(issue.estimate_point.estimate, "type", None) in ["points", "time"]
else None
)
@@ -62,9 +61,7 @@ def entity_issue_state_activity_task(issue_cycle_data, user_id, slug, action):
)
if activity_records:
EntityIssueStateActivity.objects.bulk_create(
activity_records, batch_size=10
)
EntityIssueStateActivity.objects.bulk_create(activity_records, batch_size=10)
except Exception as e:
log_exception(e)
@@ -26,7 +26,6 @@ from plane.db.models import (
Page,
PageLog,
Issue,
WorkspaceMember,
ProjectPage,
User,
)
@@ -142,11 +142,7 @@ def handle_multi_properties(
return
# Case 2: If the existing value is not empty and the requested value is not empty
if (
existing_value
and requested_value
and existing_value[0] != requested_value[0]
):
if existing_value and requested_value and existing_value[0] != requested_value[0]:
bulk_property_activity.append(
IssuePropertyActivity(
workspace_id=property.workspace_id,
@@ -367,9 +363,7 @@ def track_property_file(
@shared_task
def issue_property_activity(
existing_values, requested_values, issue_id, user_id, epoch
):
def issue_property_activity(existing_values, requested_values, issue_id, user_id, epoch):
"""
This function is used to create an activity for the issue property changes.
"""
@@ -99,4 +99,4 @@ def process_initiative_notifications(
}
except Exception as e:
log_exception(e)
return {"success": False, "error": str(e)}
return {"success": False, "error": str(e)}
-1
View File
@@ -21,7 +21,6 @@ from plane.db.models import (
UserRecentVisit,
)
from plane.utils.exception_logger import log_exception
from plane.ee.bgtasks.move_page import move_page
from plane.bgtasks.copy_s3_object import copy_s3_objects_of_description_and_assets
from plane.bgtasks.page_transaction_task import page_transaction
from plane.ee.utils.page_descendants import get_descendant_page_ids
@@ -50,14 +50,8 @@ def track_description(
project_activities,
epoch,
):
if current_instance.get("description_html") != requested_data.get(
"description_html"
):
last_activity = (
WorkspaceActivity.objects.filter(project_id=project_id)
.order_by("-created_at")
.first()
)
if current_instance.get("description_html") != requested_data.get("description_html"):
last_activity = WorkspaceActivity.objects.filter(project_id=project_id).order_by("-created_at").first()
if (
last_activity is not None
and last_activity.field == "description_html"
@@ -158,15 +152,9 @@ def track_target_date(
actor_id=actor_id,
verb="updated",
old_value=(
current_instance.get("target_date")
if current_instance.get("target_date") is not None
else ""
),
new_value=(
requested_data.get("target_date")
if requested_data.get("target_date") is not None
else ""
current_instance.get("target_date") if current_instance.get("target_date") is not None else ""
),
new_value=(requested_data.get("target_date") if requested_data.get("target_date") is not None else ""),
field="target_date",
project_id=project_id,
workspace_id=workspace_id,
@@ -192,15 +180,9 @@ def track_start_date(
actor_id=actor_id,
verb="updated",
old_value=(
current_instance.get("start_date")
if current_instance.get("start_date") is not None
else ""
),
new_value=(
requested_data.get("start_date")
if requested_data.get("start_date") is not None
else ""
current_instance.get("start_date") if current_instance.get("start_date") is not None else ""
),
new_value=(requested_data.get("start_date") if requested_data.get("start_date") is not None else ""),
field="start_date",
project_id=project_id,
workspace_id=workspace_id,
@@ -384,9 +366,7 @@ def track_deploy_board(
WorkspaceActivity(
actor_id=actor_id,
workspace_id=workspace_id,
verb="published"
if requested_data.get("deploy_board")
else "unpublished",
verb="published" if requested_data.get("deploy_board") else "unpublished",
old_value=current_instance.get("deploy_board"),
new_value=requested_data.get("deploy_board"),
field="deploy_board",
@@ -421,9 +401,7 @@ def track_members(
workspace_id=workspace_id,
old_identifier=None,
new_identifier=member.id,
comment="joined the project"
if requested_data.get("joined", None)
else "added the member",
comment="joined the project" if requested_data.get("joined", None) else "added the member",
epoch=epoch,
)
)
@@ -443,9 +421,7 @@ def track_members(
workspace_id=workspace_id,
old_identifier=member.id,
new_identifier=None,
comment="removed the members"
if current_instance.get("removed")
else "left the project",
comment="removed the members" if current_instance.get("removed") else "left the project",
epoch=epoch,
)
)
@@ -604,9 +580,7 @@ def update_project_activity(
]
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
for key in requested_data:
func = project_activity_MAPPER.get(key)
@@ -666,9 +640,7 @@ def create_comment_activity(
epoch,
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
project_activities.append(
WorkspaceActivity(
@@ -695,9 +667,7 @@ def update_comment_activity(
epoch,
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
if current_instance.get("comment_html") != requested_data.get("comment_html"):
project_activities.append(
@@ -750,9 +720,7 @@ def create_link_activity(
epoch,
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
project_activities.append(
WorkspaceActivity(
@@ -779,9 +747,7 @@ def update_link_activity(
epoch,
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
if current_instance.get("url") != requested_data.get("url"):
project_activities.append(
@@ -810,9 +776,7 @@ def delete_link_activity(
project_activities,
epoch,
):
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
project_activities.append(
WorkspaceActivity(
@@ -839,9 +803,7 @@ def create_attachment_activity(
epoch,
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
project_activities.append(
WorkspaceActivity(
@@ -927,9 +889,7 @@ def delete_project_reaction_activity(
project_activities,
epoch,
):
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
if current_instance and current_instance.get("reaction") is not None:
project_activities.append(
WorkspaceActivity(
@@ -969,11 +929,7 @@ def create_comment_reaction_activity(
.first()
)
comment = IssueComment.objects.get(pk=comment_id, project_id=project_id)
if (
comment is not None
and comment_reaction_id is not None
and comment_id is not None
):
if comment is not None and comment_reaction_id is not None and comment_id is not None:
project_activities.append(
WorkspaceActivity(
actor_id=actor_id,
@@ -34,9 +34,7 @@ def create_recurring_workitem_activity(
epoch,
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
recurring_work_item_activities.append(
RecurringWorkItemTaskActivity(
@@ -167,7 +165,6 @@ def track_priority(
recurring_work_item_activities,
epoch,
):
if requested_data.get("priority") != current_instance.get("priority"):
recurring_work_item_activities.append(
RecurringWorkItemTaskActivity(
@@ -194,7 +191,6 @@ def track_state(
recurring_work_item_activities,
epoch,
):
if requested_data.get("state").get("id") != current_instance.get("state").get("id"):
recurring_work_item_activities.append(
RecurringWorkItemTaskActivity(
@@ -293,9 +289,7 @@ def track_labels(
):
# Get label IDs from both requested and current data
requested_labels = (
set([str(lbl.get("id")) for lbl in requested_data.get("labels", [])])
if requested_data is not None
else set()
set([str(lbl.get("id")) for lbl in requested_data.get("labels", [])]) if requested_data is not None else set()
)
current_labels = (
set([str(lbl.get("id")) for lbl in current_instance.get("labels", [])])
@@ -361,9 +355,7 @@ def track_modules(
):
# Get label IDs from both requested and current data
requested_modules = (
set([str(lbl.get("id")) for lbl in requested_data.get("modules", [])])
if requested_data is not None
else set()
set([str(lbl.get("id")) for lbl in requested_data.get("modules", [])]) if requested_data is not None else set()
)
current_modules = (
set([str(lbl.get("id")) for lbl in current_instance.get("modules", [])])
@@ -427,26 +419,11 @@ def track_type(
recurring_work_item_activities,
epoch,
):
if requested_data.get("type").get("id") != current_instance.get("type").get("id"):
new_type_id = (
requested_data.get("type").get("id") if requested_data.get("type") else None
)
old_type_id = (
current_instance.get("type").get("id")
if current_instance.get("type")
else None
)
new_type_name = (
requested_data.get("type").get("name")
if requested_data.get("type")
else None
)
old_type_name = (
current_instance.get("type").get("name")
if current_instance.get("type")
else None
)
new_type_id = requested_data.get("type").get("id") if requested_data.get("type") else None
old_type_id = current_instance.get("type").get("id") if current_instance.get("type") else None
new_type_name = requested_data.get("type").get("name") if requested_data.get("type") else None
old_type_name = current_instance.get("type").get("name") if current_instance.get("type") else None
recurring_work_item_activities.append(
RecurringWorkItemTaskActivity(
project_id=project_id,
@@ -501,9 +478,7 @@ def track_description_html(
recurring_work_item_activities,
epoch,
):
if current_instance.get("description_html") != requested_data.get(
"description_html"
):
if current_instance.get("description_html") != requested_data.get("description_html"):
recurring_work_item_activities.append(
RecurringWorkItemTaskActivity(
recurring_workitem_task_id=recurring_workitem_task_id,
@@ -529,7 +504,6 @@ def track_properties(
recurring_work_item_activities,
epoch,
):
# Handle both dictionary and list formats for properties
requested_properties_data = requested_data.get("properties", [])
current_properties_data = current_instance.get("properties", [])
@@ -538,16 +512,12 @@ def track_properties(
# Convert properties to the expected dictionary format
if isinstance(requested_properties_data, list):
requested_values = {
str(prop.get("id")): prop.get("values", [])
for prop in requested_properties_data
if prop.get("id")
str(prop.get("id")): prop.get("values", []) for prop in requested_properties_data if prop.get("id")
}
if isinstance(current_properties_data, list):
current_values = {
str(prop.get("id")): prop.get("values", [])
for prop in current_properties_data
if prop.get("id")
str(prop.get("id")): prop.get("values", []) for prop in current_properties_data if prop.get("id")
}
# Log the activity
@@ -595,11 +565,8 @@ def update_recurring_workitem_activity(
recurring_work_item_activities,
epoch,
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
current_instance = json.loads(current_instance) if current_instance is not None else None
RECURRING_WORKITEM_TEMPLATE_ACTIVITY_MAPPER = {
"name": track_name,
@@ -621,9 +588,7 @@ def update_recurring_workitem_activity(
}
if requested_data.get("workitem_blueprint") is not None:
for key in requested_data.get("workitem_blueprint", {}).keys():
func = RECURRING_WORKITEM_TEMPLATE_ACTIVITY_MAPPER.get(key)
if func is not None:
func(
@@ -689,9 +654,7 @@ def recurring_work_item_activity(
)
# Save all the values to database
RecurringWorkItemTaskActivity.objects.bulk_create(
recurring_work_item_activities
)
RecurringWorkItemTaskActivity.objects.bulk_create(recurring_work_item_activities)
return
except Exception as e:
@@ -146,11 +146,7 @@ def handle_multi_properties(
return
# Case 2: If the existing value is not empty and the requested value is not empty
if (
existing_value
and requested_value
and existing_value[0] != requested_value[0]
):
if existing_value and requested_value and existing_value[0] != requested_value[0]:
bulk_property_activity.append(
RecurringWorkItemTaskActivity(
workspace_id=property.workspace_id,
@@ -380,9 +376,9 @@ def recurring_work_item_property_activity(
"""
# Get the issue
recurring_workitem_task = RecurringWorkitemTask.objects.select_related(
"workitem_blueprint"
).get(id=recurring_workitem_task_id)
recurring_workitem_task = RecurringWorkitemTask.objects.select_related("workitem_blueprint").get(
id=recurring_workitem_task_id
)
issue_type_id = recurring_workitem_task.workitem_blueprint.type.get("id")
if not issue_type_id:
@@ -431,11 +427,9 @@ def recurring_work_item_property_activity(
epoch=epoch,
)
# Create the bulk activity
RecurringWorkItemTaskActivity.objects.bulk_create(bulk_property_activity)
return
except Exception as e:
log_exception(e)
@@ -45,14 +45,14 @@ def create_work_item_from_template(self, recurring_workitem_task_id: str):
try:
# Get the recurring task
recurring_task = RecurringWorkitemTask.objects.select_related(
"workitem_blueprint", "project", "workspace"
).filter(id=recurring_workitem_task_id).first()
recurring_task = (
RecurringWorkitemTask.objects.select_related("workitem_blueprint", "project", "workspace")
.filter(id=recurring_workitem_task_id)
.first()
)
if not recurring_task:
logger.info(
f"Recurring task {recurring_workitem_task_id} not found, skipping execution"
)
logger.info(f"Recurring task {recurring_workitem_task_id} not found, skipping execution")
return {"status": "skipped", "message": "Task not found"}
slug = recurring_task.workspace.slug
@@ -64,16 +64,12 @@ def create_work_item_from_template(self, recurring_workitem_task_id: str):
# Check if task is enabled
if not recurring_task.enabled:
logger.info(
f"Recurring task {recurring_workitem_task_id} is disabled, skipping execution"
)
logger.info(f"Recurring task {recurring_workitem_task_id} is disabled, skipping execution")
return {"status": "skipped", "message": "Task is disabled"}
# Check if task has expired
if recurring_task.end_at and timezone.now() > recurring_task.end_at:
logger.info(
f"Recurring task {recurring_workitem_task_id} has expired, skipping execution"
)
logger.info(f"Recurring task {recurring_workitem_task_id} has expired, skipping execution")
return {"status": "skipped", "message": "Task has expired"}
# Create log entry
@@ -100,9 +96,7 @@ def create_work_item_from_template(self, recurring_workitem_task_id: str):
# check for the workflows for the issue creation
# if the state is changed later
if workitem_blueprint_first.state.get("id"):
workflow_state_manager = WorkflowStateManager(
project_id=project_id, slug=slug
)
workflow_state_manager = WorkflowStateManager(project_id=project_id, slug=slug)
if workflow_state_manager.validate_issue_creation(
state_id=workitem_blueprint_first.state.get("id"), user_id=user_id
):
@@ -111,17 +105,13 @@ def create_work_item_from_template(self, recurring_workitem_task_id: str):
# get all the states in the project and create a mapping dict
state_map = {
str(state.id): str(state.id)
for state in State.objects.filter(
project_id=project_id, workspace_id=workspace_id
)
for state in State.objects.filter(project_id=project_id, workspace_id=workspace_id)
}
# get the labels in the project and create a mapping dict
label_map = {
str(label.id): str(label.id)
for label in Label.objects.filter(
project_id=project_id, workspace_id=workspace_id
)
for label in Label.objects.filter(project_id=project_id, workspace_id=workspace_id)
}
# get all the workitem types in the project and create a mapping dict
@@ -137,25 +127,19 @@ def create_work_item_from_template(self, recurring_workitem_task_id: str):
# get all the workitem properties in the project and create a mapping dict
workitem_property_map = {
str(prop.id): str(prop.id)
for prop in IssueProperty.objects.filter(
project_id=project_id, workspace_id=workspace_id
)
for prop in IssueProperty.objects.filter(project_id=project_id, workspace_id=workspace_id)
}
# get all the workitem property options in the project and create a mapping dict
workitem_property_option_map = {
str(option.id): str(option.id)
for option in IssuePropertyOption.objects.filter(
project_id=project_id, workspace_id=workspace_id
)
for option in IssuePropertyOption.objects.filter(project_id=project_id, workspace_id=workspace_id)
}
# get all the estimates in the project and create a mapping dict
estimate_point_map = {
str(estimate.id): str(estimate.id)
for estimate in EstimatePoint.objects.filter(
project_id=project_id, workspace_id=workspace_id
)
for estimate in EstimatePoint.objects.filter(project_id=project_id, workspace_id=workspace_id)
}
work_item = create_workitems(
@@ -196,9 +180,7 @@ def create_work_item_from_template(self, recurring_workitem_task_id: str):
updated_by_id=user_id,
)
logger.info(
f"Successfully created work item {work_item.id} from recurring task {recurring_workitem_task_id}"
)
logger.info(f"Successfully created work item {work_item.id} from recurring task {recurring_workitem_task_id}")
return {
"status": "success",
@@ -208,9 +190,7 @@ def create_work_item_from_template(self, recurring_workitem_task_id: str):
}
except RecurringWorkitemTask.DoesNotExist:
error_msg = (
f"RecurringWorkitemTask with ID {recurring_workitem_task_id} not found"
)
error_msg = f"RecurringWorkitemTask with ID {recurring_workitem_task_id} not found"
logger.error(error_msg)
return {"status": "error", "message": error_msg}
+17 -50
View File
@@ -101,12 +101,8 @@ def _get_property_value_data(
property_type_handlers = {
PropertyTypeEnum.TEXT: lambda v: {"value_text": str(v)},
PropertyTypeEnum.BOOLEAN: lambda v: {"value_boolean": bool(v)},
PropertyTypeEnum.DECIMAL: lambda v: {
"value_decimal": float(v) if v is not None else 0.0
},
PropertyTypeEnum.DATETIME: lambda v: {
"value_datetime": _parse_datetime_value(v)
},
PropertyTypeEnum.DECIMAL: lambda v: {"value_decimal": float(v) if v is not None else 0.0},
PropertyTypeEnum.DATETIME: lambda v: {"value_datetime": _parse_datetime_value(v)},
PropertyTypeEnum.URL: lambda v: {"value_text": str(v)},
PropertyTypeEnum.EMAIL: lambda v: {"value_text": str(v)},
PropertyTypeEnum.RELATION: lambda v: {"value_uuid": uuid.UUID(str(v))},
@@ -146,9 +142,7 @@ def _get_property_value_data(
return {"value_text": str(value)}
except (ValueError, TypeError) as e:
logger.warning(
f"Failed to process value {value} for property {property_obj.id}: {e}"
)
logger.warning(f"Failed to process value {value} for property {property_obj.id}: {e}")
return None
@@ -322,9 +316,7 @@ def create_workitem_types(workitem_type_data, project_id, workspace_id, user_id)
is_multi=property.get("is_multi", False),
)
created_issue_property.save(created_by_id=user_id)
workitem_property_map[str(property.get("id"))] = str(
created_issue_property.id
)
workitem_property_map[str(property.get("id"))] = str(created_issue_property.id)
logger.info(f"Issue property created: {created_issue_property.id}")
# create options
if created_issue_property.property_type == PropertyTypeEnum.OPTION:
@@ -340,12 +332,8 @@ def create_workitem_types(workitem_type_data, project_id, workspace_id, user_id)
project_id=project_id,
)
created_issue_property_option.save(created_by_id=user_id)
workitem_property_option_map[str(value.get("id"))] = str(
created_issue_property_option.id
)
logger.info(
f"Issue property option created: {created_issue_property_option.id}"
)
workitem_property_option_map[str(value.get("id"))] = str(created_issue_property_option.id)
logger.info(f"Issue property option created: {created_issue_property_option.id}")
return workitem_type_map, workitem_property_map, workitem_property_option_map
@@ -370,9 +358,7 @@ def create_epics(epic_data, project_id, workspace_id, user_id):
)
issue_type.save(created_by_id=user_id)
logger.info(f"Epic created: {issue_type.id}")
created_project_issue_type = ProjectIssueType(
project_id=project_id, issue_type=issue_type, level=1
)
created_project_issue_type = ProjectIssueType(project_id=project_id, issue_type=issue_type, level=1)
created_project_issue_type.save(created_by_id=user_id)
logger.info(f"Epic project issue type created: {created_project_issue_type.id}")
for property in epic_data.get("properties", []):
@@ -487,9 +473,7 @@ def create_issue_property_values(
# Log the activity
issue_property_activity.delay(
existing_values={
str(prop["property_id"]): prop["values"] for prop in existing_prop_values
},
existing_values={str(prop["property_id"]): prop["values"] for prop in existing_prop_values},
requested_values=converted_properties,
issue_id=issue.id,
user_id=user_id,
@@ -530,7 +514,6 @@ def create_workitems(
created_workitems = []
# Create workitems
for blueprint in workitem_blueprints:
# Check if the state is present in the state map
if blueprint.state and state_map:
state_id = state_map.get(str(blueprint.state.get("id")))
@@ -593,9 +576,7 @@ def create_workitems(
created_by_id=user_id,
)
logger.info(f"Issue activity created: {new_issue_id}")
requested_data = json.dumps(
IssueDetailSerializer(new_issue).data, cls=DjangoJSONEncoder
)
requested_data = json.dumps(IssueDetailSerializer(new_issue).data, cls=DjangoJSONEncoder)
slug = Workspace.objects.get(id=workspace_id).slug
# trigger the webhook
@@ -616,9 +597,7 @@ def create_workitems(
user_id=user_id,
is_creating=True,
)
logger.info(
f"triggered the workitem description version for the workitem: {new_issue_id}"
)
logger.info(f"triggered the workitem description version for the workitem: {new_issue_id}")
# Create the assignees
IssueAssignee.objects.bulk_create(
@@ -633,9 +612,7 @@ def create_workitems(
for member_id in ProjectMember.objects.filter(
project_id=project_id,
workspace_id=workspace_id,
member_id__in=[
str(assignee.get("id")) for assignee in blueprint.assignees
],
member_id__in=[str(assignee.get("id")) for assignee in blueprint.assignees],
).values_list("member_id", flat=True)
],
batch_size=BULK_CREATE_BATCH_SIZE,
@@ -681,9 +658,7 @@ def create_workitems(
@shared_task
def create_project_from_template(
template_id, project_id, user_id, state_map, origin=None
):
def create_project_from_template(template_id, project_id, user_id, state_map, origin=None):
try:
"""
Create a project from a template and copy the workitems, labels, estimates, etc.
@@ -707,22 +682,16 @@ def create_project_from_template(
# create estimates
if project_template.estimates:
estimate_point_map = create_estimates(
project_template.estimates, project_id, project, user_id
)
estimate_point_map = create_estimates(project_template.estimates, project_id, project, user_id)
# create labels
if project_template.labels:
label_map = create_labels(
project_template.labels, project_id, workspace_id, user_id
)
label_map = create_labels(project_template.labels, project_id, workspace_id, user_id)
# create workitem types
if project_template.workitem_types:
workitem_type_map, workitem_property_map, workitem_property_option_map = (
create_workitem_types(
project_template.workitem_types, project_id, workspace_id, user_id
)
workitem_type_map, workitem_property_map, workitem_property_option_map = create_workitem_types(
project_template.workitem_types, project_id, workspace_id, user_id
)
# create epics
@@ -884,9 +853,7 @@ def create_subworkitems(workitem_template_id, project_id, workitem_id, user_id):
"created_by_id": user_id,
}
)
property_values_to_create.append(
IssuePropertyValue(**property_value_data)
)
property_values_to_create.append(IssuePropertyValue(**property_value_data))
if property_values_to_create:
IssuePropertyValue.objects.bulk_create(

Some files were not shown because too many files have changed in this diff Show More