diff --git a/apps/api/plane/ee/serializers/api/issue_property.py b/apps/api/plane/ee/serializers/api/issue_property.py index c761067274..4a6810ebfa 100644 --- a/apps/api/plane/ee/serializers/api/issue_property.py +++ b/apps/api/plane/ee/serializers/api/issue_property.py @@ -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., user@example.com)" - ) + raise serializers.ValidationError("Must be a valid email address (e.g., user@example.com)") # 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: diff --git a/apps/api/plane/ee/views/api/issue_property/base.py b/apps/api/plane/ee/views/api/issue_property/base.py index aeafe22602..77479fe130 100644 --- a/apps/api/plane/ee/views/api/issue_property/base.py +++ b/apps/api/plane/ee/views/api/issue_property/base.py @@ -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()