[SILO-723] feat: add options in custom property during create flow (#4893)

* [SILO-723] feat: add options in custom property in create property API

- To update or add more options, users have to use the dedicated options endpoint

* add validation for issue type and project during property creation

* pass type and project context in update apis
This commit is contained in:
Surya Prashanth
2025-11-28 19:07:00 +05:30
committed by GitHub
parent bf6012df9c
commit 50d482dc75
2 changed files with 198 additions and 155 deletions
@@ -1,5 +1,9 @@
# Django imports
from django.db import models, transaction
# Module imports
from rest_framework import serializers
from plane.db.models import ProjectIssueType
from plane.ee.serializers import BaseSerializer
from plane.ee.models import (
IssueProperty,
@@ -7,12 +11,35 @@ from plane.ee.models import (
IssuePropertyValue,
IssuePropertyActivity,
RelationTypeEnum,
PropertyTypeEnum,
)
class IssuePropertyOptionAPISerializer(BaseSerializer):
class Meta:
model = IssuePropertyOption
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"sort_order",
"property",
"logo_props",
"deleted_at",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
class IssuePropertyAPISerializer(BaseSerializer):
relation_type = serializers.ChoiceField(
choices=RelationTypeEnum.choices, required=False, allow_null=True
relation_type = serializers.ChoiceField(choices=RelationTypeEnum.choices, required=False, allow_null=True)
options = serializers.ListField(
child=IssuePropertyOptionAPISerializer(),
required=False,
write_only=True,
help_text="List of options to create when property_type is OPTION. Each option should have 'name', optionally 'description', 'is_default', 'external_id', and 'external_source'.",
)
class Meta:
@@ -32,23 +59,113 @@ class IssuePropertyAPISerializer(BaseSerializer):
"updated_by",
]
def to_representation(self, instance):
"""Override to add options field to response"""
representation = super().to_representation(instance)
# Add options field for OPTION type properties
if instance.property_type == PropertyTypeEnum.OPTION:
options = IssuePropertyOption.objects.filter(
property=instance,
deleted_at__isnull=True,
).order_by("sort_order")
representation["options"] = IssuePropertyOptionAPISerializer(options, many=True).data
else:
representation["options"] = []
return representation
class IssuePropertyOptionAPISerializer(BaseSerializer):
class Meta:
model = IssuePropertyOption
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"sort_order",
"property",
"logo_props",
"deleted_at",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
def validate_options(self, value):
"""Validate options field - ensure it's only used with OPTION property type"""
if not value:
return value
# Get property_type from the initial data (since this is field-level validation)
property_type = self.initial_data.get("property_type")
if property_type != PropertyTypeEnum.OPTION:
raise serializers.ValidationError("Options can only be provided when property_type is OPTION")
# Validate that only one option is marked as default
# Note: Individual option validation (name, etc.) is handled by IssuePropertyOptionAPISerializer
default_count = sum(1 for option_data in value if option_data.get("is_default", False))
if default_count > 1:
raise serializers.ValidationError("Only one option can be marked as default")
return value
def validate(self, data):
"""Validate the serializer data"""
# validate if the issue type is in the project sent in the request
issue_type = self.context.get("issue_type")
project = self.context.get("project")
if issue_type and project:
project_issue_type = ProjectIssueType.objects.filter(issue_type=issue_type, project=project).first()
if not project_issue_type:
raise serializers.ValidationError("Issue type is not in the project")
return data
@transaction.atomic
def create(self, validated_data):
"""Override create to handle options creation"""
options_data = validated_data.pop("options", [])
property_type = validated_data.get("property_type")
# Create the property
issue_property = super().create(validated_data)
# Create options if property_type is OPTION and options are provided
if property_type == PropertyTypeEnum.OPTION and options_data:
# Get workspace and project from the created property
workspace = issue_property.workspace
project = issue_property.project
# Get the last sort order
last_sort_order = IssuePropertyOption.objects.filter(
project=project,
property=issue_property,
).aggregate(largest=models.Max("sort_order"))["largest"]
sort_order = last_sort_order + 10000 if last_sort_order is not None else 10000
# Create options using the nested serializer's validated data
for option_validated_data in options_data:
# Check for external_id conflicts
external_id = option_validated_data.get("external_id")
external_source = option_validated_data.get("external_source")
if external_id and external_source:
existing_option = IssuePropertyOption.objects.filter(
workspace=workspace,
project=project,
property=issue_property,
external_source=external_source,
external_id=external_id,
deleted_at__isnull=True,
).first()
if existing_option:
raise serializers.ValidationError(
{
"options": f"Option with external_id '{external_id}' and external_source '{external_source}' already exists"
}
)
# Create the option using validated data from nested serializer
IssuePropertyOption.objects.create(
workspace=workspace,
project=project,
property=issue_property,
name=option_validated_data.get("name"),
description=option_validated_data.get("description", ""),
is_default=option_validated_data.get("is_default", False),
external_id=external_id,
external_source=external_source,
sort_order=sort_order,
)
sort_order += 10000
return issue_property
class IssuePropertyValueAPISerializer(BaseSerializer):
@@ -159,9 +276,7 @@ class WorkItemPropertyValueRequestSerializer(serializers.Serializer):
# Check if required
if property_obj.is_required and not value:
raise serializers.ValidationError(
f"{property_obj.display_name} is required"
)
raise serializers.ValidationError(f"{property_obj.display_name} is required")
# If value is empty and not required, no further validation needed
if not value and value != 0 and value is not False:
@@ -182,9 +297,7 @@ class WorkItemPropertyValueRequestSerializer(serializers.Serializer):
try:
url_validator(value)
except DjangoValidationError:
raise serializers.ValidationError(
"Must be a valid URL (e.g., https://example.com)"
)
raise serializers.ValidationError("Must be a valid URL (e.g., https://example.com)")
# EMAIL - must be valid email format
elif property_type == PropertyTypeEnum.EMAIL:
@@ -193,9 +306,7 @@ class WorkItemPropertyValueRequestSerializer(serializers.Serializer):
try:
validate_email(value)
except DjangoValidationError:
raise serializers.ValidationError(
"Must be a valid email address (e.g., [email protected])"
)
raise serializers.ValidationError("Must be a valid email address (e.g., [email protected])")
# DATETIME - accepts YYYY-MM-DD or YYYY-MM-DD HH:MM:SS
elif property_type == PropertyTypeEnum.DATETIME:
@@ -212,9 +323,7 @@ class WorkItemPropertyValueRequestSerializer(serializers.Serializer):
continue
if not parsed:
raise serializers.ValidationError(
"Must be a valid date in format YYYY-MM-DD or YYYY-MM-DD HH:MM:SS"
)
raise serializers.ValidationError("Must be a valid date in format YYYY-MM-DD or YYYY-MM-DD HH:MM:SS")
# DECIMAL - must be a number
elif property_type == PropertyTypeEnum.DECIMAL:
@@ -237,51 +346,33 @@ class WorkItemPropertyValueRequestSerializer(serializers.Serializer):
if property_obj.is_multi:
if isinstance(value, list):
if not value:
raise serializers.ValidationError(
"List cannot be empty for multi-value property"
)
raise serializers.ValidationError("List cannot be empty for multi-value property")
# Validate each UUID in the list
uuid_list = []
for v in value:
if not isinstance(v, str):
raise serializers.ValidationError(
"Each value in list must be a string (UUID)"
)
raise serializers.ValidationError("Each value in list must be a string (UUID)")
try:
uuid_obj = uuid.UUID(str(v), version=4)
uuid_list.append(uuid_obj)
except (ValueError, AttributeError):
raise serializers.ValidationError(
f"Invalid UUID format: {v}"
)
raise serializers.ValidationError(f"Invalid UUID format: {v}")
# Check if all options exist for this property
existing_count = IssuePropertyOption.objects.filter(
property=property_obj, id__in=uuid_list
).count()
existing_count = IssuePropertyOption.objects.filter(property=property_obj, id__in=uuid_list).count()
if existing_count != len(uuid_list):
raise serializers.ValidationError(
"One or more selected options do not exist for this property"
)
raise serializers.ValidationError("One or more selected options do not exist for this property")
elif isinstance(value, str):
# Single value also acceptable for multi-value property
try:
uuid_obj = uuid.UUID(str(value), version=4)
except (ValueError, AttributeError):
raise serializers.ValidationError(
"Must be a valid UUID for a property option"
)
raise serializers.ValidationError("Must be a valid UUID for a property option")
if not IssuePropertyOption.objects.filter(
property=property_obj, id=uuid_obj
).exists():
raise serializers.ValidationError(
"Selected option does not exist for this property"
)
if not IssuePropertyOption.objects.filter(property=property_obj, id=uuid_obj).exists():
raise serializers.ValidationError("Selected option does not exist for this property")
else:
raise serializers.ValidationError(
"Must be a string (UUID) or list of UUIDs"
)
raise serializers.ValidationError("Must be a string (UUID) or list of UUIDs")
else:
# Single value only
if not isinstance(value, str):
@@ -289,46 +380,32 @@ class WorkItemPropertyValueRequestSerializer(serializers.Serializer):
try:
uuid_obj = uuid.UUID(str(value), version=4)
except (ValueError, AttributeError):
raise serializers.ValidationError(
"Must be a valid UUID for a property option"
)
raise serializers.ValidationError("Must be a valid UUID for a property option")
if not IssuePropertyOption.objects.filter(
property=property_obj, id=uuid_obj
).exists():
raise serializers.ValidationError(
"Selected option does not exist for this property"
)
if not IssuePropertyOption.objects.filter(property=property_obj, id=uuid_obj).exists():
raise serializers.ValidationError("Selected option does not exist for this property")
# RELATION - must be a valid UUID or list of UUIDs (if is_multi)
elif property_type == PropertyTypeEnum.RELATION:
# Validate based on relation type
if not property_obj.relation_type:
raise serializers.ValidationError(
"Property relation type is not configured"
)
raise serializers.ValidationError("Property relation type is not configured")
# Handle multi-value properties
if property_obj.is_multi:
if isinstance(value, list):
if not value:
raise serializers.ValidationError(
"List cannot be empty for multi-value property"
)
raise serializers.ValidationError("List cannot be empty for multi-value property")
# Validate each UUID in the list
uuid_list = []
for v in value:
if not isinstance(v, str):
raise serializers.ValidationError(
"Each value in list must be a string (UUID)"
)
raise serializers.ValidationError("Each value in list must be a string (UUID)")
try:
uuid_obj = uuid.UUID(str(v), version=4)
uuid_list.append(uuid_obj)
except (ValueError, AttributeError):
raise serializers.ValidationError(
f"Invalid UUID format: {v}"
)
raise serializers.ValidationError(f"Invalid UUID format: {v}")
# Check if all relations exist
if property_obj.relation_type == RelationTypeEnum.ISSUE:
@@ -349,9 +426,7 @@ class WorkItemPropertyValueRequestSerializer(serializers.Serializer):
"One or more referenced users are not members of this workspace"
)
else:
raise serializers.ValidationError(
f"Invalid relation type: {property_obj.relation_type}"
)
raise serializers.ValidationError(f"Invalid relation type: {property_obj.relation_type}")
elif isinstance(value, str):
# Single value also acceptable for multi-value property
try:
@@ -360,27 +435,17 @@ class WorkItemPropertyValueRequestSerializer(serializers.Serializer):
raise serializers.ValidationError("Must be a valid UUID")
if property_obj.relation_type == RelationTypeEnum.ISSUE:
if not Issue.objects.filter(
workspace_id=property_obj.workspace_id, id=uuid_obj
).exists():
raise serializers.ValidationError(
"Referenced work item does not exist in this workspace"
)
if not Issue.objects.filter(workspace_id=property_obj.workspace_id, id=uuid_obj).exists():
raise serializers.ValidationError("Referenced work item does not exist in this workspace")
elif property_obj.relation_type == RelationTypeEnum.USER:
if not WorkspaceMember.objects.filter(
workspace_id=property_obj.workspace_id, member_id=uuid_obj
).exists():
raise serializers.ValidationError(
"Referenced user is not a member of this workspace"
)
raise serializers.ValidationError("Referenced user is not a member of this workspace")
else:
raise serializers.ValidationError(
f"Invalid relation type: {property_obj.relation_type}"
)
raise serializers.ValidationError(f"Invalid relation type: {property_obj.relation_type}")
else:
raise serializers.ValidationError(
"Must be a string (UUID) or list of UUIDs"
)
raise serializers.ValidationError("Must be a string (UUID) or list of UUIDs")
else:
# Single value only
if not isinstance(value, str):
@@ -391,29 +456,19 @@ class WorkItemPropertyValueRequestSerializer(serializers.Serializer):
raise serializers.ValidationError("Must be a valid UUID")
if property_obj.relation_type == RelationTypeEnum.ISSUE:
if not Issue.objects.filter(
workspace_id=property_obj.workspace_id, id=uuid_obj
).exists():
raise serializers.ValidationError(
"Referenced work item does not exist in this workspace"
)
if not Issue.objects.filter(workspace_id=property_obj.workspace_id, id=uuid_obj).exists():
raise serializers.ValidationError("Referenced work item does not exist in this workspace")
elif property_obj.relation_type == RelationTypeEnum.USER:
if not WorkspaceMember.objects.filter(
workspace_id=property_obj.workspace_id, member_id=uuid_obj
).exists():
raise serializers.ValidationError(
"Referenced user is not a member of this workspace"
)
raise serializers.ValidationError("Referenced user is not a member of this workspace")
else:
raise serializers.ValidationError(
f"Invalid relation type: {property_obj.relation_type}"
)
raise serializers.ValidationError(f"Invalid relation type: {property_obj.relation_type}")
# Unknown property type
else:
raise serializers.ValidationError(
f"Unsupported property type: {property_type}"
)
raise serializers.ValidationError(f"Unsupported property type: {property_type}")
return value
@@ -450,9 +505,7 @@ class WorkItemPropertyValueRequestSerializer(serializers.Serializer):
property_obj = self.context.get("property")
if not all([workspace_id, project_id, issue_id, property_obj]):
raise serializers.ValidationError(
"Missing required context for creating property value"
)
raise serializers.ValidationError("Missing required context for creating property value")
value = validated_data["value"]
property_type = property_obj.property_type
@@ -468,10 +521,7 @@ class WorkItemPropertyValueRequestSerializer(serializers.Serializer):
}
# Handle multi-value OPTION and RELATION properties
if (
property_type in [PropertyTypeEnum.OPTION, PropertyTypeEnum.RELATION]
and property_obj.is_multi
):
if property_type in [PropertyTypeEnum.OPTION, PropertyTypeEnum.RELATION] and property_obj.is_multi:
# Normalize to list
values = value if isinstance(value, list) else [value]
@@ -533,10 +583,7 @@ class WorkItemPropertyValueRequestSerializer(serializers.Serializer):
property_obj = instance.property
# Handle multi-value OPTION and RELATION properties
if (
property_type in [PropertyTypeEnum.OPTION, PropertyTypeEnum.RELATION]
and property_obj.is_multi
):
if property_type in [PropertyTypeEnum.OPTION, PropertyTypeEnum.RELATION] and property_obj.is_multi:
# Normalize to list
values = new_value if isinstance(new_value, list) else [new_value]
@@ -556,9 +603,7 @@ class WorkItemPropertyValueRequestSerializer(serializers.Serializer):
"issue_id": instance.issue_id,
"property": property_obj,
"external_id": validated_data.get("external_id", instance.external_id),
"external_source": validated_data.get(
"external_source", instance.external_source
),
"external_source": validated_data.get("external_source", instance.external_source),
}
for v in values:
@@ -608,36 +653,32 @@ class WorkItemPropertyValueResponseSerializer(serializers.ModelSerializer):
property_id = serializers.UUIDField(read_only=True)
issue_id = serializers.UUIDField(read_only=True)
value = serializers.SerializerMethodField(
help_text="The actual value, formatted according to property type"
)
value_type = serializers.SerializerMethodField(
help_text="Type of the value"
)
value = serializers.SerializerMethodField(help_text="The actual value, formatted according to property type")
value_type = serializers.SerializerMethodField(help_text="Type of the value")
class Meta:
model = IssuePropertyValue
fields = [
'id',
'property_id',
'issue_id',
'value',
'value_type',
'external_id',
'external_source',
'created_at',
'updated_at',
"id",
"property_id",
"issue_id",
"value",
"value_type",
"external_id",
"external_source",
"created_at",
"updated_at",
]
read_only_fields = [
'id',
'property_id',
'issue_id',
'value',
'value_type',
'external_id',
'external_source',
'created_at',
'updated_at',
"id",
"property_id",
"issue_id",
"value",
"value_type",
"external_id",
"external_source",
"created_at",
"updated_at",
]
def get_value(self, obj):
@@ -655,9 +696,7 @@ class WorkItemPropertyValueResponseSerializer(serializers.ModelSerializer):
]:
return obj.value_text
elif property_type == PropertyTypeEnum.DATETIME:
return (
obj.value_datetime.strftime("%Y-%m-%d") if obj.value_datetime else None
)
return obj.value_datetime.strftime("%Y-%m-%d") if obj.value_datetime else None
elif property_type == PropertyTypeEnum.DECIMAL:
return obj.value_decimal
elif property_type == PropertyTypeEnum.BOOLEAN:
@@ -155,7 +155,9 @@ class IssuePropertyListCreateAPIEndpoint(BaseAPIView):
)
data = request.data
issue_property_serializer = self.serializer_class(data=data)
issue_property_serializer = self.serializer_class(
data=data, context={"issue_type": issue_type, "project": project}
)
issue_property_serializer.is_valid(raise_exception=True)
issue_property_serializer.save(
workspace=workspace,
@@ -238,7 +240,7 @@ class IssuePropertyDetailAPIEndpoint(BaseAPIView):
issue_type_id=type_id,
pk=property_id,
)
serializer = self.serializer_class(issue_property)
serializer = self.serializer_class(issue_property, context={"issue_type": type_id, "project": project_id})
return Response(serializer.data, status=status.HTTP_200_OK)
# update issue property by id
@@ -279,7 +281,9 @@ class IssuePropertyDetailAPIEndpoint(BaseAPIView):
)
data = request.data
issue_property_serializer = self.serializer_class(issue_property, data=data, partial=True)
issue_property_serializer = self.serializer_class(
issue_property, data=data, partial=True, context={"issue_type": type_id, "project": project_id}
)
issue_property_serializer.is_valid(raise_exception=True)
issue_property_serializer.save()