From 380295801dcff1473621ef9ad5957f4a1d1dc8ae Mon Sep 17 00:00:00 2001 From: Vansh Gilhotra Date: Mon, 29 Dec 2025 18:21:39 +0530 Subject: [PATCH 1/2] feat: add custom fields backend models, API, and tests - Add IssueProperty and IssuePropertyValue models - Add migration 0113 with unique constraints - Add ViewSets for property CRUD and bulk operations - Add serializers with type-specific validation - Add 30 tests (8 model, 10 serializer, 12 API) - all passing - Support 6 field types: text, number, date, boolean, select, multi_select --- apps/api/plane/api/urls/project.py | 42 ++ apps/api/plane/app/serializers/__init__.py | 3 + apps/api/plane/app/serializers/issue.py | 305 ++++++++++++++ apps/api/plane/app/urls/issue.py | 41 ++ apps/api/plane/app/views/__init__.py | 6 + apps/api/plane/app/views/project/property.py | 362 +++++++++++++++++ .../0113_custom_fields_issue_property.py | 281 +++++++++++++ apps/api/plane/db/models/__init__.py | 3 + apps/api/plane/db/models/issue.py | 133 ++++++ .../contract/app/test_issue_property_api.py | 381 ++++++++++++++++++ .../tests/unit/models/test_issue_property.py | 264 ++++++++++++ .../unit/serializers/test_issue_property.py | 328 +++++++++++++++ 12 files changed, 2149 insertions(+) create mode 100644 apps/api/plane/app/views/project/property.py create mode 100644 apps/api/plane/db/migrations/0113_custom_fields_issue_property.py create mode 100644 apps/api/plane/tests/contract/app/test_issue_property_api.py create mode 100644 apps/api/plane/tests/unit/models/test_issue_property.py create mode 100644 apps/api/plane/tests/unit/serializers/test_issue_property.py diff --git a/apps/api/plane/api/urls/project.py b/apps/api/plane/api/urls/project.py index 9cf9291aa6e..3e5f70308d9 100644 --- a/apps/api/plane/api/urls/project.py +++ b/apps/api/plane/api/urls/project.py @@ -5,6 +5,11 @@ ProjectDetailAPIEndpoint, ProjectArchiveUnarchiveAPIEndpoint, ) +from plane.app.views import ( + IssuePropertyViewSet, + IssuePropertyValueViewSet, + BulkIssuePropertyValueEndpoint, +) urlpatterns = [ path( @@ -22,4 +27,41 @@ ProjectArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["post", "delete"]), name="project-archive-unarchive", ), + # ========================================================================== + # CUSTOM FIELDS (Issue Properties) + # ========================================================================== + # Project-level property definitions + path( + "workspaces//projects//properties/", + IssuePropertyViewSet.as_view({"get": "list", "post": "create"}), + name="project-issue-properties", + ), + path( + "workspaces//projects//properties//", + IssuePropertyViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-issue-property-detail", + ), + # Issue-level property values + path( + "workspaces//projects//issues//property-values/", + IssuePropertyValueViewSet.as_view({"get": "list", "post": "create"}), + name="issue-property-values", + ), + path( + "workspaces//projects//issues//property-values//", + IssuePropertyValueViewSet.as_view({"delete": "destroy"}), + name="issue-property-value-detail", + ), + # Bulk custom fields endpoint - get/set all custom fields for an issue + path( + "workspaces//projects//issues//custom-fields/", + BulkIssuePropertyValueEndpoint.as_view(), + name="issue-custom-fields", + ), ] diff --git a/apps/api/plane/app/serializers/__init__.py b/apps/api/plane/app/serializers/__init__.py index 759f27ed6e6..a25c49f3f42 100644 --- a/apps/api/plane/app/serializers/__init__.py +++ b/apps/api/plane/app/serializers/__init__.py @@ -76,6 +76,9 @@ IssueVersionDetailSerializer, IssueDescriptionVersionDetailSerializer, IssueListDetailSerializer, + IssuePropertySerializer, + IssuePropertyLiteSerializer, + IssuePropertyValueSerializer, ) from .module import ( diff --git a/apps/api/plane/app/serializers/issue.py b/apps/api/plane/app/serializers/issue.py index 5e3b93ab674..5d9230ecab3 100644 --- a/apps/api/plane/app/serializers/issue.py +++ b/apps/api/plane/app/serializers/issue.py @@ -38,6 +38,8 @@ IssueDescriptionVersion, ProjectMember, EstimatePoint, + IssueProperty, + IssuePropertyValue, ) from plane.utils.content_validator import ( validate_html_content, @@ -266,6 +268,18 @@ def create(self, validated_data): except IntegrityError: pass + # Handle custom_fields if provided in initial_data + custom_fields = self.initial_data.get("custom_fields") + if custom_fields: + self._handle_custom_fields( + issue=issue, + custom_fields=custom_fields, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + return issue def update(self, instance, validated_data): @@ -320,10 +334,83 @@ def update(self, instance, validated_data): except IntegrityError: pass + # Handle custom_fields if provided in initial_data + custom_fields = self.initial_data.get("custom_fields") + if custom_fields: + self._handle_custom_fields( + issue=instance, + custom_fields=custom_fields, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + # Time updation occues even when other related models are updated instance.updated_at = timezone.now() return super().update(instance, validated_data) + def _handle_custom_fields(self, issue, custom_fields, project_id, workspace_id, created_by_id, updated_by_id): + """ + Helper method to handle custom_fields on create/update. + custom_fields format: {"property_key": value, ...} + """ + if not custom_fields or not isinstance(custom_fields, dict): + return + + # Get all properties for this project by key + properties = IssueProperty.objects.filter( + project_id=project_id, + key__in=custom_fields.keys(), + deleted_at__isnull=True, + ) + property_map = {prop.key: prop for prop in properties} + + # Get existing values for this issue + existing_values = IssuePropertyValue.objects.filter( + issue=issue, + property__key__in=custom_fields.keys(), + deleted_at__isnull=True, + ) + existing_map = {ev.property.key: ev for ev in existing_values} + + values_to_create = [] + values_to_update = [] + + for key, value in custom_fields.items(): + if key not in property_map: + continue # Skip unknown properties + + prop = property_map[key] + + if key in existing_map: + # Update existing value + existing_value = existing_map[key] + existing_value.value = value + existing_value.updated_by_id = updated_by_id + values_to_update.append(existing_value) + else: + # Create new value + values_to_create.append( + IssuePropertyValue( + issue=issue, + property=prop, + value=value, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + ) + + # Bulk create new values + if values_to_create: + IssuePropertyValue.objects.bulk_create(values_to_create, batch_size=10) + + # Bulk update existing values + if values_to_update: + IssuePropertyValue.objects.bulk_update(values_to_update, ["value", "updated_by_id"], batch_size=10) + class IssueActivitySerializer(BaseSerializer): actor_detail = UserLiteSerializer(read_only=True, source="actor") @@ -930,6 +1017,25 @@ class Meta(IssueSerializer.Meta): ] read_only_fields = fields + def to_representation(self, instance): + """Override to inject custom_fields into the response""" + data = super().to_representation(instance) + + # Add custom_fields - fetch all property values for this issue + property_values = IssuePropertyValue.objects.filter( + issue_id=instance.id, + deleted_at__isnull=True, + ).select_related("property") + + # Build custom_fields dict keyed by property key + custom_fields = {} + for pv in property_values: + if pv.property: + custom_fields[pv.property.key] = pv.value + + data["custom_fields"] = custom_fields + return data + class IssuePublicSerializer(BaseSerializer): project_detail = ProjectLiteSerializer(read_only=True, source="project") @@ -1023,3 +1129,202 @@ class Meta: "updated_by", ] read_only_fields = ["workspace", "project", "issue"] + + +class IssuePropertySerializer(BaseSerializer): + """Serializer for IssueProperty model - defines custom field schemas""" + + class Meta: + model = IssueProperty + fields = [ + "id", + "name", + "key", + "description", + "property_type", + "options", + "default_value", + "is_required", + "sort_order", + "is_active", + "project_id", + "workspace_id", + "external_source", + "external_id", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + read_only_fields = [ + "id", + "key", + "workspace", + "project", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + + def validate_name(self, value): + """Ensure property name is unique within the project""" + project_id = self.context.get("project_id") + property_qs = IssueProperty.objects.filter( + project_id=project_id, name__iexact=value, deleted_at__isnull=True + ) + if self.instance: + property_qs = property_qs.exclude(id=self.instance.pk) + if property_qs.exists(): + raise serializers.ValidationError("Property with this name already exists in the project") + return value + + def validate(self, attrs): + """Validate options for SELECT and MULTI_SELECT types""" + property_type = attrs.get("property_type", getattr(self.instance, "property_type", None)) + options = attrs.get("options", getattr(self.instance, "options", [])) + + if property_type in ["select", "multi_select"]: + if not options or not isinstance(options, list): + raise serializers.ValidationError({ + "options": "Options are required for select and multi_select types" + }) + # Validate each option has required fields + for i, option in enumerate(options): + if not isinstance(option, dict): + raise serializers.ValidationError({ + "options": f"Option at index {i} must be an object" + }) + if "value" not in option: + raise serializers.ValidationError({ + "options": f"Option at index {i} must have a 'value' field" + }) + + # Validate default_value matches property_type + default_value = attrs.get("default_value") + if default_value is not None and property_type: + if property_type == "boolean" and not isinstance(default_value, bool): + raise serializers.ValidationError({ + "default_value": "Default value must be a boolean for boolean type" + }) + if property_type == "number" and not isinstance(default_value, (int, float)): + raise serializers.ValidationError({ + "default_value": "Default value must be a number for number type" + }) + if property_type == "select" and default_value: + valid_values = [opt.get("value") for opt in options] + if default_value not in valid_values: + raise serializers.ValidationError({ + "default_value": "Default value must be one of the defined options" + }) + if property_type == "multi_select" and default_value: + if not isinstance(default_value, list): + raise serializers.ValidationError({ + "default_value": "Default value must be a list for multi_select type" + }) + valid_values = [opt.get("value") for opt in options] + for val in default_value: + if val not in valid_values: + raise serializers.ValidationError({ + "default_value": f"'{val}' is not a valid option" + }) + + return attrs + + +class IssuePropertyLiteSerializer(BaseSerializer): + """Lightweight serializer for IssueProperty - used in nested responses""" + + class Meta: + model = IssueProperty + fields = [ + "id", + "name", + "key", + "property_type", + "options", + "is_required", + "is_active", + ] + + +class IssuePropertyValueSerializer(BaseSerializer): + """Serializer for IssuePropertyValue model - stores custom field values per issue""" + property_detail = IssuePropertyLiteSerializer(source="property", read_only=True) + + class Meta: + model = IssuePropertyValue + fields = [ + "id", + "issue_id", + "property_id", + "property_detail", + "value", + "project_id", + "workspace_id", + "created_at", + "updated_at", + ] + read_only_fields = [ + "id", + "workspace", + "project", + "created_at", + "updated_at", + ] + + def validate(self, attrs): + """Validate value matches the property type""" + property_obj = attrs.get("property") + value = attrs.get("value") + + if property_obj is None and self.instance: + property_obj = self.instance.property + + if property_obj and value is not None: + property_type = property_obj.property_type + + if property_type == "text" and not isinstance(value, str): + raise serializers.ValidationError({ + "value": "Value must be a string for text type" + }) + if property_type == "number" and not isinstance(value, (int, float)): + raise serializers.ValidationError({ + "value": "Value must be a number for number type" + }) + if property_type == "boolean" and not isinstance(value, bool): + raise serializers.ValidationError({ + "value": "Value must be a boolean for boolean type" + }) + if property_type == "date" and value: + # Date should be stored as ISO string + if not isinstance(value, str): + raise serializers.ValidationError({ + "value": "Value must be a date string (YYYY-MM-DD) for date type" + }) + if property_type == "select": + valid_values = [opt.get("value") for opt in property_obj.options] + if value not in valid_values: + raise serializers.ValidationError({ + "value": f"'{value}' is not a valid option" + }) + if property_type == "multi_select": + if not isinstance(value, list): + raise serializers.ValidationError({ + "value": "Value must be a list for multi_select type" + }) + valid_values = [opt.get("value") for opt in property_obj.options] + for val in value: + if val not in valid_values: + raise serializers.ValidationError({ + "value": f"'{val}' is not a valid option" + }) + + # Validate required fields + if property_obj and property_obj.is_required and value is None: + raise serializers.ValidationError({ + "value": f"'{property_obj.name}' is a required field" + }) + + return attrs + diff --git a/apps/api/plane/app/urls/issue.py b/apps/api/plane/app/urls/issue.py index 1d809e248f5..41adc91eefa 100644 --- a/apps/api/plane/app/urls/issue.py +++ b/apps/api/plane/app/urls/issue.py @@ -27,6 +27,10 @@ WorkItemDescriptionVersionEndpoint, IssueMetaEndpoint, IssueDetailIdentifierEndpoint, + # Custom Fields + IssuePropertyViewSet, + IssuePropertyValueViewSet, + BulkIssuePropertyValueEndpoint, ) urlpatterns = [ @@ -279,4 +283,41 @@ IssueDetailIdentifierEndpoint.as_view(), name="issue-detail-identifier", ), + # ========================================================================== + # CUSTOM FIELDS (Issue Properties) + # ========================================================================== + # Project-level property definitions + path( + "workspaces//projects//properties/", + IssuePropertyViewSet.as_view({"get": "list", "post": "create"}), + name="project-issue-properties", + ), + path( + "workspaces//projects//properties//", + IssuePropertyViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-issue-properties", + ), + # Issue-level property values + path( + "workspaces//projects//issues//property-values/", + IssuePropertyValueViewSet.as_view({"get": "list", "post": "create"}), + name="issue-property-values", + ), + path( + "workspaces//projects//issues//property-values//", + IssuePropertyValueViewSet.as_view({"delete": "destroy"}), + name="issue-property-values", + ), + # Bulk custom fields endpoint - get/set all custom fields for an issue + path( + "workspaces//projects//issues//custom-fields/", + BulkIssuePropertyValueEndpoint.as_view(), + name="issue-custom-fields", + ), ] diff --git a/apps/api/plane/app/views/__init__.py b/apps/api/plane/app/views/__init__.py index 7a0e5cb3a28..864625ab4c3 100644 --- a/apps/api/plane/app/views/__init__.py +++ b/apps/api/plane/app/views/__init__.py @@ -20,6 +20,12 @@ ProjectMemberPreferenceEndpoint, ) +from .project.property import ( + IssuePropertyViewSet, + IssuePropertyValueViewSet, + BulkIssuePropertyValueEndpoint, +) + from .user.base import ( UserEndpoint, UpdateUserOnBoardedEndpoint, diff --git a/apps/api/plane/app/views/project/property.py b/apps/api/plane/app/views/project/property.py new file mode 100644 index 00000000000..fb956a57685 --- /dev/null +++ b/apps/api/plane/app/views/project/property.py @@ -0,0 +1,362 @@ +# Django imports +from django.db import IntegrityError +from django.db.models import Prefetch + +# Third Party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.app.permissions import ROLE, ProjectBasePermission, allow_permission +from plane.app.serializers import ( + IssuePropertySerializer, + IssuePropertyValueSerializer, + IssuePropertyLiteSerializer, +) +from plane.app.views.base import BaseAPIView, BaseViewSet +from plane.db.models import ( + IssueProperty, + IssuePropertyValue, + Issue, + ProjectMember, +) + + +class IssuePropertyViewSet(BaseViewSet): + """ + ViewSet for managing IssueProperty (custom field definitions) within a project. + Supports full CRUD operations for project admins. + """ + + serializer_class = IssuePropertySerializer + model = IssueProperty + permission_classes = [ProjectBasePermission] + + def get_queryset(self): + return ( + self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(project__project_projectmember__member=self.request.user) + .filter(project__project_projectmember__is_active=True) + .select_related("project", "workspace", "created_by", "updated_by") + .distinct() + ) + .order_by("sort_order", "-created_at") + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def list(self, request, slug, project_id): + """List all custom field definitions for a project""" + # Filter by is_active if specified + is_active = request.GET.get("is_active") + queryset = self.get_queryset() + + if is_active is not None: + queryset = queryset.filter(is_active=is_active.lower() == "true") + + serializer = IssuePropertySerializer(queryset, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def retrieve(self, request, slug, project_id, pk): + """Get a single custom field definition""" + issue_property = self.get_object() + serializer = IssuePropertySerializer(issue_property) + return Response(serializer.data, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN]) + def create(self, request, slug, project_id): + """Create a new custom field definition""" + try: + serializer = IssuePropertySerializer( + data=request.data, + context={"project_id": project_id}, + ) + if serializer.is_valid(): + serializer.save(project_id=project_id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError: + return Response( + {"error": "Property with this name or key already exists in the project"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + @allow_permission([ROLE.ADMIN]) + def partial_update(self, request, slug, project_id, pk): + """Update a custom field definition (partial update)""" + issue_property = self.get_object() + + # Prevent changing the key after creation (for API stability) + if "key" in request.data: + return Response( + {"error": "Property key cannot be modified after creation"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = IssuePropertySerializer( + instance=issue_property, + data=request.data, + context={"project_id": project_id}, + partial=True, + ) + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @allow_permission([ROLE.ADMIN]) + def destroy(self, request, slug, project_id, pk): + """Delete a custom field definition (soft delete)""" + issue_property = self.get_object() + + # This will cascade delete all IssuePropertyValue records for this property + issue_property.delete() + + return Response(status=status.HTTP_204_NO_CONTENT) + + +class IssuePropertyValueViewSet(BaseViewSet): + """ + ViewSet for managing IssuePropertyValue (custom field values) for specific issues. + """ + + serializer_class = IssuePropertyValueSerializer + model = IssuePropertyValue + permission_classes = [ProjectBasePermission] + + def get_queryset(self): + return ( + self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(issue_id=self.kwargs.get("issue_id")) + .filter(project__project_projectmember__member=self.request.user) + .filter(project__project_projectmember__is_active=True) + .select_related("property", "issue", "project", "workspace") + .distinct() + ) + .order_by("property__sort_order") + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def list(self, request, slug, project_id, issue_id): + """List all custom field values for an issue""" + queryset = self.get_queryset() + serializer = IssuePropertyValueSerializer(queryset, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def create(self, request, slug, project_id, issue_id): + """Create or update a custom field value for an issue""" + property_id = request.data.get("property_id") + + if not property_id: + return Response( + {"error": "property_id is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Verify property exists and belongs to the project + try: + issue_property = IssueProperty.objects.get( + id=property_id, + project_id=project_id, + deleted_at__isnull=True, + ) + except IssueProperty.DoesNotExist: + return Response( + {"error": "Property not found in this project"}, + status=status.HTTP_404_NOT_FOUND, + ) + + # Verify issue exists + try: + issue = Issue.objects.get( + id=issue_id, + project_id=project_id, + ) + except Issue.DoesNotExist: + return Response( + {"error": "Issue not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + # Check if value already exists (upsert behavior) + existing_value = IssuePropertyValue.objects.filter( + issue_id=issue_id, + property_id=property_id, + deleted_at__isnull=True, + ).first() + + if existing_value: + # Update existing value + serializer = IssuePropertyValueSerializer( + instance=existing_value, + data=request.data, + context={"project_id": project_id}, + partial=True, + ) + else: + # Create new value + serializer = IssuePropertyValueSerializer( + data=request.data, + context={"project_id": project_id}, + ) + + if serializer.is_valid(): + serializer.save( + issue_id=issue_id, + property_id=property_id, + project_id=project_id, + ) + return Response( + serializer.data, + status=status.HTTP_200_OK if existing_value else status.HTTP_201_CREATED, + ) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def destroy(self, request, slug, project_id, issue_id, pk): + """Delete a custom field value""" + property_value = self.get_object() + property_value.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class BulkIssuePropertyValueEndpoint(BaseAPIView): + """ + Endpoint for bulk operations on custom field values for an issue. + Allows setting multiple custom field values at once. + """ + + permission_classes = [ProjectBasePermission] + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def post(self, request, slug, project_id, issue_id): + """ + Bulk create/update custom field values for an issue. + Request body: {"custom_fields": {"property_key": value, ...}} + """ + custom_fields = request.data.get("custom_fields", {}) + + if not custom_fields or not isinstance(custom_fields, dict): + return Response( + {"error": "custom_fields must be a non-empty object with key-value pairs"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Verify issue exists + try: + issue = Issue.objects.get( + id=issue_id, + project_id=project_id, + ) + except Issue.DoesNotExist: + return Response( + {"error": "Issue not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + # Get all properties for this project by key + properties = IssueProperty.objects.filter( + project_id=project_id, + key__in=custom_fields.keys(), + deleted_at__isnull=True, + ) + property_map = {prop.key: prop for prop in properties} + + # Validate all keys exist + invalid_keys = set(custom_fields.keys()) - set(property_map.keys()) + if invalid_keys: + return Response( + {"error": f"Unknown property keys: {', '.join(invalid_keys)}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get existing values for this issue + existing_values = IssuePropertyValue.objects.filter( + issue_id=issue_id, + property__key__in=custom_fields.keys(), + deleted_at__isnull=True, + ) + existing_map = {ev.property.key: ev for ev in existing_values} + + results = [] + errors = [] + + for key, value in custom_fields.items(): + prop = property_map[key] + + # Prepare data for serializer + data = {"value": value, "property": prop.id} + + if key in existing_map: + # Update existing + serializer = IssuePropertyValueSerializer( + instance=existing_map[key], + data=data, + context={"project_id": project_id}, + partial=True, + ) + else: + # Create new + serializer = IssuePropertyValueSerializer( + data=data, + context={"project_id": project_id}, + ) + + if serializer.is_valid(): + serializer.save( + issue_id=issue_id, + property_id=prop.id, + project_id=project_id, + ) + results.append({key: serializer.data}) + else: + errors.append({key: serializer.errors}) + + if errors: + return Response( + {"results": results, "errors": errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + return Response({"results": results}, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def get(self, request, slug, project_id, issue_id): + """ + Get all custom field values for an issue in a flat format. + Response: {"custom_fields": {"property_key": value, ...}} + """ + # Verify issue exists + try: + issue = Issue.objects.get( + id=issue_id, + project_id=project_id, + ) + except Issue.DoesNotExist: + return Response( + {"error": "Issue not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + # Get all property values for this issue + property_values = IssuePropertyValue.objects.filter( + issue_id=issue_id, + deleted_at__isnull=True, + ).select_related("property") + + # Build flat response keyed by property key + custom_fields = {} + for pv in property_values: + custom_fields[pv.property.key] = pv.value + + return Response({"custom_fields": custom_fields}, status=status.HTTP_200_OK) diff --git a/apps/api/plane/db/migrations/0113_custom_fields_issue_property.py b/apps/api/plane/db/migrations/0113_custom_fields_issue_property.py new file mode 100644 index 00000000000..6eb6992ee24 --- /dev/null +++ b/apps/api/plane/db/migrations/0113_custom_fields_issue_property.py @@ -0,0 +1,281 @@ +# Generated by Django - Custom Fields Migration +# Creates IssueProperty and IssuePropertyValue models for custom fields feature + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("db", "0112_auto_20251124_0603"), + ] + + operations = [ + # Create IssueProperty model (field definitions) + migrations.CreateModel( + name="IssueProperty", + fields=[ + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Last Modified At"), + ), + ( + "deleted_at", + models.DateTimeField(blank=True, null=True, verbose_name="Deleted At"), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField(max_length=255, verbose_name="Property Name"), + ), + ( + "key", + models.SlugField( + help_text="Auto-generated immutable key for API usage", + max_length=255, + verbose_name="Property Key", + ), + ), + ( + "description", + models.TextField(blank=True, default=""), + ), + ( + "property_type", + models.CharField( + choices=[ + ("text", "Text"), + ("number", "Number"), + ("date", "Date"), + ("boolean", "Boolean"), + ("select", "Select"), + ("multi_select", "Multi Select"), + ], + default="text", + max_length=20, + verbose_name="Property Type", + ), + ), + ( + "options", + models.JSONField(blank=True, default=list), + ), + ( + "default_value", + models.JSONField(blank=True, null=True), + ), + ( + "is_required", + models.BooleanField(default=False), + ), + ( + "sort_order", + models.FloatField(default=65535), + ), + ( + "is_active", + models.BooleanField(default=True), + ), + ( + "external_source", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "external_id", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_project", + to="db.project", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_workspace", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Issue Property", + "verbose_name_plural": "Issue Properties", + "db_table": "issue_properties", + "ordering": ("sort_order", "-created_at"), + }, + ), + # Create IssuePropertyValue model (field values per issue) + migrations.CreateModel( + name="IssuePropertyValue", + fields=[ + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Last Modified At"), + ), + ( + "deleted_at", + models.DateTimeField(blank=True, null=True, verbose_name="Deleted At"), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "value", + models.JSONField(blank=True, null=True), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="property_values", + to="db.issue", + ), + ), + ( + "property", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="values", + to="db.issueproperty", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_project", + to="db.project", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_workspace", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Issue Property Value", + "verbose_name_plural": "Issue Property Values", + "db_table": "issue_property_values", + "ordering": ("-created_at",), + }, + ), + # Add unique constraints for IssueProperty + migrations.AddConstraint( + model_name="issueproperty", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("project", "key"), + name="issue_property_unique_project_key_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="issueproperty", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("project", "name"), + name="issue_property_unique_project_name_when_deleted_at_null", + ), + ), + # Add unique constraint for IssuePropertyValue + migrations.AddConstraint( + model_name="issuepropertyvalue", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("issue", "property"), + name="issue_property_value_unique_issue_property_when_deleted_at_null", + ), + ), + # Add indexes for IssuePropertyValue + migrations.AddIndex( + model_name="issuepropertyvalue", + index=models.Index( + fields=["issue", "property"], + name="issue_prope_issue_i_idx", + ), + ), + migrations.AddIndex( + model_name="issuepropertyvalue", + index=models.Index( + fields=["property"], + name="issue_prope_propert_idx", + ), + ), + ] diff --git a/apps/api/plane/db/models/__init__.py b/apps/api/plane/db/models/__init__.py index 41fd32bd557..616d2ef0724 100644 --- a/apps/api/plane/db/models/__init__.py +++ b/apps/api/plane/db/models/__init__.py @@ -42,6 +42,9 @@ IssueVote, IssueVersion, IssueDescriptionVersion, + IssueProperty, + IssuePropertyValue, + IssuePropertyTypeChoices, ) from .module import Module, ModuleIssue, ModuleLink, ModuleMember, ModuleUserProperties from .notification import EmailNotificationLog, Notification, UserNotificationPreference diff --git a/apps/api/plane/db/models/issue.py b/apps/api/plane/db/models/issue.py index 07ebdf0a41e..ff38feb8be5 100644 --- a/apps/api/plane/db/models/issue.py +++ b/apps/api/plane/db/models/issue.py @@ -844,3 +844,136 @@ def log_issue_description_version(cls, issue, user): except Exception as e: log_exception(e) return False + + +class IssuePropertyTypeChoices(models.TextChoices): + """Choices for custom field types""" + TEXT = "text", "Text" + NUMBER = "number", "Number" + DATE = "date", "Date" + BOOLEAN = "boolean", "Boolean" + SELECT = "select", "Select" + MULTI_SELECT = "multi_select", "Multi Select" + + +class IssueProperty(ProjectBaseModel): + """ + Model to define custom field schemas for issues within a project. + Each property defines a field that can be attached to any issue in the project. + """ + name = models.CharField(max_length=255, verbose_name="Property Name") + key = models.SlugField( + max_length=255, + verbose_name="Property Key", + help_text="Auto-generated immutable key for API usage" + ) + description = models.TextField(blank=True, default="") + property_type = models.CharField( + max_length=20, + choices=IssuePropertyTypeChoices.choices, + default=IssuePropertyTypeChoices.TEXT, + verbose_name="Property Type" + ) + # Options for SELECT and MULTI_SELECT types + # Format: [{"value": "option1", "color": "#FF0000"}, {"value": "option2", "color": "#00FF00"}] + options = models.JSONField(default=list, blank=True) + # Default value for new issues (stored as JSON for type flexibility) + default_value = models.JSONField(null=True, blank=True) + is_required = models.BooleanField(default=False) + sort_order = models.FloatField(default=65535) + is_active = models.BooleanField(default=True) + external_source = models.CharField(max_length=255, null=True, blank=True) + external_id = models.CharField(max_length=255, blank=True, null=True) + + class Meta: + unique_together = ["project", "key", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["project", "key"], + condition=Q(deleted_at__isnull=True), + name="issue_property_unique_project_key_when_deleted_at_null", + ), + models.UniqueConstraint( + fields=["project", "name"], + condition=Q(deleted_at__isnull=True), + name="issue_property_unique_project_name_when_deleted_at_null", + ), + ] + verbose_name = "Issue Property" + verbose_name_plural = "Issue Properties" + db_table = "issue_properties" + ordering = ("sort_order", "-created_at") + + def save(self, *args, **kwargs): + # Auto-generate key from name if not provided (only on creation) + if self._state.adding and not self.key: + from django.utils.text import slugify + base_key = slugify(self.name).replace("-", "_") + # Ensure uniqueness within project + key = base_key + counter = 1 + while IssueProperty.objects.filter( + project=self.project, key=key, deleted_at__isnull=True + ).exists(): + key = f"{base_key}_{counter}" + counter += 1 + self.key = key + + # Auto-increment sort_order + if self._state.adding: + last_order = IssueProperty.objects.filter( + project=self.project + ).aggregate(largest=models.Max("sort_order"))["largest"] + if last_order is not None: + self.sort_order = last_order + 10000 + + super().save(*args, **kwargs) + + def __str__(self): + return f"{self.name} ({self.key})" + + +class IssuePropertyValue(ProjectBaseModel): + """ + Model to store custom field values for specific issues. + Each record links an issue to a property definition and stores its value. + """ + issue = models.ForeignKey( + Issue, + on_delete=models.CASCADE, + related_name="property_values" + ) + property = models.ForeignKey( + IssueProperty, + on_delete=models.CASCADE, + related_name="values" + ) + # Value stored as JSON to support all types: + # - TEXT: "string value" + # - NUMBER: 123 or 123.45 + # - DATE: "2024-01-15" + # - BOOLEAN: true/false + # - SELECT: "selected_option" + # - MULTI_SELECT: ["option1", "option2"] + value = models.JSONField(null=True, blank=True) + + class Meta: + unique_together = ["issue", "property", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["issue", "property"], + condition=Q(deleted_at__isnull=True), + name="issue_property_value_unique_issue_property_when_deleted_at_null", + ) + ] + verbose_name = "Issue Property Value" + verbose_name_plural = "Issue Property Values" + db_table = "issue_property_values" + ordering = ("-created_at",) + indexes = [ + models.Index(fields=["issue", "property"]), + models.Index(fields=["property"]), + ] + + def __str__(self): + return f"{self.issue.name} - {self.property.name}: {self.value}" diff --git a/apps/api/plane/tests/contract/app/test_issue_property_api.py b/apps/api/plane/tests/contract/app/test_issue_property_api.py new file mode 100644 index 00000000000..6162072f82b --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_issue_property_api.py @@ -0,0 +1,381 @@ +import pytest +from django.urls import reverse +from rest_framework import status + +from plane.db.models import ( + Project, + IssueProperty, + IssuePropertyValue, + Issue, + State, + ProjectMember, +) + + +@pytest.mark.contract +class TestIssuePropertyAPI: + """Contract tests for IssueProperty API endpoints""" + + @pytest.fixture + def project_with_member(self, db, workspace, create_user): + """Create a project with the test user as admin member""" + project = Project.objects.create( + name="Test Project", + identifier="TEST", + workspace=workspace, + ) + ProjectMember.objects.create( + project=project, + member=create_user, + role=20, # Admin + is_active=True, + ) + return project + + @pytest.fixture + def issue_in_project(self, db, workspace, project_with_member): + """Create an issue in the test project""" + state = State.objects.create( + name="Open", + project=project_with_member, + workspace=workspace, + ) + return Issue.objects.create( + name="Test Issue", + project=project_with_member, + workspace=workspace, + state=state, + ) + + @pytest.mark.django_db + def test_list_properties_empty(self, session_client, workspace, project_with_member): + """Test listing properties when none exist""" + url = f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}/properties/" + response = session_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert response.data == [] + + @pytest.mark.django_db + def test_create_text_property(self, session_client, workspace, project_with_member): + """Test creating a text property""" + url = f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}/properties/" + data = { + "name": "Client Name", + "property_type": "text", + "description": "Name of the client", + } + response = session_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_201_CREATED + assert response.data["name"] == "Client Name" + assert response.data["key"] == "client_name" + assert response.data["property_type"] == "text" + + @pytest.mark.django_db + def test_create_select_property(self, session_client, workspace, project_with_member): + """Test creating a select property with options""" + url = f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}/properties/" + data = { + "name": "Priority Level", + "property_type": "select", + "options": [ + {"value": "Low", "color": "#00FF00"}, + {"value": "High", "color": "#FF0000"}, + ], + } + response = session_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_201_CREATED + assert response.data["property_type"] == "select" + assert len(response.data["options"]) == 2 + + @pytest.mark.django_db + def test_list_properties(self, session_client, workspace, project_with_member): + """Test listing properties after creation""" + # Create some properties + IssueProperty.objects.create( + name="Prop 1", + property_type="text", + project=project_with_member, + workspace=workspace, + ) + IssueProperty.objects.create( + name="Prop 2", + property_type="number", + project=project_with_member, + workspace=workspace, + ) + + url = f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}/properties/" + response = session_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 2 + + @pytest.mark.django_db + def test_retrieve_property(self, session_client, workspace, project_with_member): + """Test retrieving a single property""" + prop = IssueProperty.objects.create( + name="Test Property", + property_type="text", + project=project_with_member, + workspace=workspace, + ) + + url = f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}/properties/{prop.id}/" + response = session_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert response.data["name"] == "Test Property" + # Compare UUID objects or convert both to strings + assert str(response.data["id"]) == str(prop.id) + + @pytest.mark.django_db + def test_update_property(self, session_client, workspace, project_with_member): + """Test updating a property (except key)""" + prop = IssueProperty.objects.create( + name="Test Property", + property_type="text", + project=project_with_member, + workspace=workspace, + ) + + url = f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}/properties/{prop.id}/" + data = { + "name": "Updated Name", + "description": "Updated description", + } + response = session_client.patch(url, data, format="json") + + assert response.status_code == status.HTTP_200_OK + assert response.data["name"] == "Updated Name" + assert response.data["description"] == "Updated description" + # Key should remain unchanged + assert response.data["key"] == "test_property" + + @pytest.mark.django_db + def test_update_property_key_fails(self, session_client, workspace, project_with_member): + """Test that updating the key fails""" + prop = IssueProperty.objects.create( + name="Test Property", + property_type="text", + project=project_with_member, + workspace=workspace, + ) + + url = f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}/properties/{prop.id}/" + data = { + "key": "new_key", + } + response = session_client.patch(url, data, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db + def test_delete_property(self, session_client, workspace, project_with_member): + """Test deleting a property""" + prop = IssueProperty.objects.create( + name="Test Property", + property_type="text", + project=project_with_member, + workspace=workspace, + ) + + url = f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}/properties/{prop.id}/" + response = session_client.delete(url) + + assert response.status_code == status.HTTP_204_NO_CONTENT + + +@pytest.mark.contract +class TestIssuePropertyValueAPI: + """Contract tests for IssuePropertyValue API endpoints""" + + @pytest.fixture + def project_with_member(self, db, workspace, create_user): + """Create a project with the test user as admin member""" + project = Project.objects.create( + name="Test Project", + identifier="TEST", + workspace=workspace, + ) + ProjectMember.objects.create( + project=project, + member=create_user, + role=20, # Admin + is_active=True, + ) + return project + + @pytest.fixture + def issue_in_project(self, db, workspace, project_with_member): + """Create an issue in the test project""" + state = State.objects.create( + name="Open", + project=project_with_member, + workspace=workspace, + ) + return Issue.objects.create( + name="Test Issue", + project=project_with_member, + workspace=workspace, + state=state, + ) + + @pytest.fixture + def text_property(self, db, workspace, project_with_member): + """Create a text property""" + return IssueProperty.objects.create( + name="Client Name", + property_type="text", + project=project_with_member, + workspace=workspace, + ) + + @pytest.mark.django_db + def test_set_property_value( + self, session_client, workspace, project_with_member, issue_in_project, text_property + ): + """Test setting a property value on an issue""" + url = ( + f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}" + f"/issues/{issue_in_project.id}/property-values/" + ) + data = { + "property_id": str(text_property.id), + "value": "Acme Corp", + } + response = session_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_201_CREATED + assert response.data["value"] == "Acme Corp" + + @pytest.mark.django_db + def test_get_property_values( + self, session_client, workspace, project_with_member, issue_in_project, text_property + ): + """Test getting all property values for an issue""" + # Create a property value + IssuePropertyValue.objects.create( + issue=issue_in_project, + property=text_property, + value="Test Value", + project=project_with_member, + workspace=workspace, + ) + + url = ( + f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}" + f"/issues/{issue_in_project.id}/property-values/" + ) + response = session_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 1 + assert response.data[0]["value"] == "Test Value" + + +@pytest.mark.contract +class TestBulkCustomFieldsAPI: + """Contract tests for bulk custom fields endpoint""" + + @pytest.fixture + def project_with_member(self, db, workspace, create_user): + """Create a project with the test user as admin member""" + project = Project.objects.create( + name="Test Project", + identifier="TEST", + workspace=workspace, + ) + ProjectMember.objects.create( + project=project, + member=create_user, + role=20, # Admin + is_active=True, + ) + return project + + @pytest.fixture + def issue_in_project(self, db, workspace, project_with_member): + """Create an issue in the test project""" + state = State.objects.create( + name="Open", + project=project_with_member, + workspace=workspace, + ) + return Issue.objects.create( + name="Test Issue", + project=project_with_member, + workspace=workspace, + state=state, + ) + + @pytest.fixture + def properties(self, db, workspace, project_with_member): + """Create multiple properties""" + return { + "client_name": IssueProperty.objects.create( + name="Client Name", + property_type="text", + project=project_with_member, + workspace=workspace, + ), + "priority": IssueProperty.objects.create( + name="Priority", + property_type="select", + options=[{"value": "Low"}, {"value": "High"}], + project=project_with_member, + workspace=workspace, + ), + "is_urgent": IssueProperty.objects.create( + name="Is Urgent", + property_type="boolean", + project=project_with_member, + workspace=workspace, + ), + } + + @pytest.mark.django_db + def test_bulk_set_custom_fields( + self, session_client, workspace, project_with_member, issue_in_project, properties + ): + """Test bulk setting custom fields on an issue""" + url = ( + f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}" + f"/issues/{issue_in_project.id}/custom-fields/" + ) + data = { + "custom_fields": { + "client_name": "Acme Corp", + "priority": "High", + "is_urgent": True, + } + } + response = session_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_200_OK + + @pytest.mark.django_db + def test_bulk_get_custom_fields( + self, session_client, workspace, project_with_member, issue_in_project, properties + ): + """Test getting custom fields in flat format""" + # Set some values first + IssuePropertyValue.objects.create( + issue=issue_in_project, + property=properties["client_name"], + value="Acme Corp", + project=project_with_member, + workspace=workspace, + ) + + url = ( + f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}" + f"/issues/{issue_in_project.id}/custom-fields/" + ) + response = session_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert "custom_fields" in response.data + assert response.data["custom_fields"]["client_name"] == "Acme Corp" diff --git a/apps/api/plane/tests/unit/models/test_issue_property.py b/apps/api/plane/tests/unit/models/test_issue_property.py new file mode 100644 index 00000000000..ef5119492af --- /dev/null +++ b/apps/api/plane/tests/unit/models/test_issue_property.py @@ -0,0 +1,264 @@ +import pytest +from uuid import uuid4 + +from plane.db.models import ( + IssueProperty, + IssuePropertyValue, + IssuePropertyTypeChoices, + Issue, + Project, + Workspace, + State, +) + + +@pytest.mark.unit +class TestIssuePropertyModel: + """Test the IssueProperty model""" + + @pytest.mark.django_db + def test_issue_property_creation(self, create_user, workspace): + """Test creating an issue property""" + # Create a project first + project = Project.objects.create( + name="Test Project", + identifier="TEST", + workspace=workspace, + ) + + # Create an issue property + issue_property = IssueProperty.objects.create( + name="Client Name", + property_type=IssuePropertyTypeChoices.TEXT, + project=project, + workspace=workspace, + ) + + # Verify it was created with auto-generated key + assert issue_property.id is not None + assert issue_property.name == "Client Name" + assert issue_property.key == "client_name" # Auto-generated from name + assert issue_property.property_type == "text" + assert issue_property.project == project + + @pytest.mark.django_db + def test_issue_property_key_generation(self, create_user, workspace): + """Test that key is auto-generated from name""" + project = Project.objects.create( + name="Test Project", + identifier="TEST", + workspace=workspace, + ) + + # Create property with spaces and special characters in name + prop1 = IssueProperty.objects.create( + name="Cost Center ID", + property_type=IssuePropertyTypeChoices.TEXT, + project=project, + workspace=workspace, + ) + + assert prop1.key == "cost_center_id" + + # Create another with same name - should get unique key + prop2 = IssueProperty.objects.create( + name="Cost Center ID", + property_type=IssuePropertyTypeChoices.NUMBER, + project=project, + workspace=workspace, + ) + + # Second one should have incremented key + assert prop2.key.startswith("cost_center_id") + + @pytest.mark.django_db + def test_issue_property_select_type_with_options(self, create_user, workspace): + """Test creating a SELECT type property with options""" + project = Project.objects.create( + name="Test Project", + identifier="TEST", + workspace=workspace, + ) + + options = [ + {"value": "Q1", "color": "#FF0000"}, + {"value": "Q2", "color": "#00FF00"}, + {"value": "Q3", "color": "#0000FF"}, + {"value": "Q4", "color": "#FFFF00"}, + ] + + issue_property = IssueProperty.objects.create( + name="Quarter", + property_type=IssuePropertyTypeChoices.SELECT, + options=options, + project=project, + workspace=workspace, + ) + + assert issue_property.property_type == "select" + assert len(issue_property.options) == 4 + assert issue_property.options[0]["value"] == "Q1" + + @pytest.mark.django_db + def test_issue_property_sort_order(self, create_user, workspace): + """Test that sort_order auto-increments""" + project = Project.objects.create( + name="Test Project", + identifier="TEST", + workspace=workspace, + ) + + prop1 = IssueProperty.objects.create( + name="Prop 1", + property_type=IssuePropertyTypeChoices.TEXT, + project=project, + workspace=workspace, + ) + + prop2 = IssueProperty.objects.create( + name="Prop 2", + property_type=IssuePropertyTypeChoices.TEXT, + project=project, + workspace=workspace, + ) + + assert prop2.sort_order > prop1.sort_order + + +@pytest.mark.unit +class TestIssuePropertyValueModel: + """Test the IssuePropertyValue model""" + + @pytest.mark.django_db + def test_issue_property_value_creation(self, create_user, workspace): + """Test creating an issue property value""" + # Create project + project = Project.objects.create( + name="Test Project", + identifier="TEST", + workspace=workspace, + ) + + # Create state + state = State.objects.create( + name="Open", + project=project, + workspace=workspace, + ) + + # Create issue + issue = Issue.objects.create( + name="Test Issue", + project=project, + workspace=workspace, + state=state, + ) + + # Create property + issue_property = IssueProperty.objects.create( + name="Client Name", + property_type=IssuePropertyTypeChoices.TEXT, + project=project, + workspace=workspace, + ) + + # Create property value + property_value = IssuePropertyValue.objects.create( + issue=issue, + property=issue_property, + value="Acme Corp", + project=project, + workspace=workspace, + ) + + assert property_value.id is not None + assert property_value.value == "Acme Corp" + assert property_value.issue == issue + assert property_value.property == issue_property + + @pytest.mark.django_db + def test_issue_property_value_json_types(self, create_user, workspace): + """Test that value field stores different JSON types correctly""" + project = Project.objects.create( + name="Test Project", + identifier="TEST", + workspace=workspace, + ) + + state = State.objects.create( + name="Open", + project=project, + workspace=workspace, + ) + + issue = Issue.objects.create( + name="Test Issue", + project=project, + workspace=workspace, + state=state, + ) + + # Test TEXT type + text_prop = IssueProperty.objects.create( + name="Text Field", + property_type=IssuePropertyTypeChoices.TEXT, + project=project, + workspace=workspace, + ) + text_value = IssuePropertyValue.objects.create( + issue=issue, + property=text_prop, + value="Hello World", + project=project, + workspace=workspace, + ) + assert text_value.value == "Hello World" + + # Test NUMBER type + number_prop = IssueProperty.objects.create( + name="Number Field", + property_type=IssuePropertyTypeChoices.NUMBER, + project=project, + workspace=workspace, + ) + number_value = IssuePropertyValue.objects.create( + issue=issue, + property=number_prop, + value=42.5, + project=project, + workspace=workspace, + ) + assert number_value.value == 42.5 + + # Test BOOLEAN type + bool_prop = IssueProperty.objects.create( + name="Boolean Field", + property_type=IssuePropertyTypeChoices.BOOLEAN, + project=project, + workspace=workspace, + ) + bool_value = IssuePropertyValue.objects.create( + issue=issue, + property=bool_prop, + value=True, + project=project, + workspace=workspace, + ) + assert bool_value.value is True + + # Test MULTI_SELECT type (array) + multi_prop = IssueProperty.objects.create( + name="Multi Select Field", + property_type=IssuePropertyTypeChoices.MULTI_SELECT, + options=[{"value": "opt1"}, {"value": "opt2"}, {"value": "opt3"}], + project=project, + workspace=workspace, + ) + multi_value = IssuePropertyValue.objects.create( + issue=issue, + property=multi_prop, + value=["opt1", "opt2"], + project=project, + workspace=workspace, + ) + assert multi_value.value == ["opt1", "opt2"] diff --git a/apps/api/plane/tests/unit/serializers/test_issue_property.py b/apps/api/plane/tests/unit/serializers/test_issue_property.py new file mode 100644 index 00000000000..dc52f306159 --- /dev/null +++ b/apps/api/plane/tests/unit/serializers/test_issue_property.py @@ -0,0 +1,328 @@ +import pytest +from plane.app.serializers import ( + IssuePropertySerializer, + IssuePropertyValueSerializer, + IssuePropertyLiteSerializer, +) +from plane.db.models import ( + Project, + IssueProperty, + IssuePropertyValue, + IssuePropertyTypeChoices, + Issue, + State, +) + + +@pytest.mark.unit +class TestIssuePropertySerializer: + """Test the IssuePropertySerializer""" + + @pytest.mark.django_db + def test_create_text_property(self, db, workspace): + """Test creating a text type property""" + project = Project.objects.create( + name="Test Project", + identifier="TEST", + workspace=workspace, + ) + + serializer = IssuePropertySerializer( + data={ + "name": "Client Name", + "property_type": "text", + "description": "Name of the client", + }, + context={"project_id": project.id}, + ) + assert serializer.is_valid(), serializer.errors + prop = serializer.save(project_id=project.id) + + assert prop.name == "Client Name" + assert prop.key == "client_name" + assert prop.property_type == "text" + + @pytest.mark.django_db + def test_create_select_property_with_options(self, db, workspace): + """Test creating a select type property with options""" + project = Project.objects.create( + name="Test Project", + identifier="TEST", + workspace=workspace, + ) + + serializer = IssuePropertySerializer( + data={ + "name": "Priority Level", + "property_type": "select", + "options": [ + {"value": "Low", "color": "#00FF00"}, + {"value": "Medium", "color": "#FFFF00"}, + {"value": "High", "color": "#FF0000"}, + ], + }, + context={"project_id": project.id}, + ) + assert serializer.is_valid(), serializer.errors + prop = serializer.save(project_id=project.id) + + assert prop.property_type == "select" + assert len(prop.options) == 3 + + @pytest.mark.django_db + def test_create_select_property_without_options_fails(self, db, workspace): + """Test that creating a select property without options fails""" + project = Project.objects.create( + name="Test Project", + identifier="TEST", + workspace=workspace, + ) + + serializer = IssuePropertySerializer( + data={ + "name": "Priority Level", + "property_type": "select", + "options": [], # Empty options + }, + context={"project_id": project.id}, + ) + assert not serializer.is_valid() + assert "options" in serializer.errors + + @pytest.mark.django_db + def test_create_boolean_property_with_default(self, db, workspace): + """Test creating a boolean property with default value""" + project = Project.objects.create( + name="Test Project", + identifier="TEST", + workspace=workspace, + ) + + serializer = IssuePropertySerializer( + data={ + "name": "Is Urgent", + "property_type": "boolean", + "default_value": False, + }, + context={"project_id": project.id}, + ) + assert serializer.is_valid(), serializer.errors + prop = serializer.save(project_id=project.id) + + assert prop.default_value is False + + @pytest.mark.django_db + def test_create_property_duplicate_name_fails(self, db, workspace): + """Test that creating a property with duplicate name fails""" + project = Project.objects.create( + name="Test Project", + identifier="TEST", + workspace=workspace, + ) + + IssueProperty.objects.create( + name="Client Name", + property_type="text", + project=project, + workspace=workspace, + ) + + serializer = IssuePropertySerializer( + data={ + "name": "Client Name", + "property_type": "text", + }, + context={"project_id": project.id}, + ) + assert not serializer.is_valid() + assert "name" in serializer.errors + + @pytest.mark.django_db + def test_invalid_default_value_for_type(self, db, workspace): + """Test that default_value must match property_type""" + project = Project.objects.create( + name="Test Project", + identifier="TEST", + workspace=workspace, + ) + + # Boolean type with string default should fail + serializer = IssuePropertySerializer( + data={ + "name": "Is Active", + "property_type": "boolean", + "default_value": "yes", # Should be boolean, not string + }, + context={"project_id": project.id}, + ) + assert not serializer.is_valid() + assert "default_value" in serializer.errors + + +@pytest.mark.unit +class TestIssuePropertyValueSerializer: + """Test the IssuePropertyValueSerializer""" + + @pytest.mark.django_db + def test_create_text_value(self, db, workspace): + """Test creating a text property value""" + project = Project.objects.create( + name="Test Project", + identifier="TEST", + workspace=workspace, + ) + + state = State.objects.create( + name="Open", + project=project, + workspace=workspace, + ) + + issue = Issue.objects.create( + name="Test Issue", + project=project, + workspace=workspace, + state=state, + ) + + prop = IssueProperty.objects.create( + name="Client Name", + property_type="text", + project=project, + workspace=workspace, + ) + + serializer = IssuePropertyValueSerializer( + data={ + "property": prop.id, + "value": "Acme Corp", + }, + context={"project_id": project.id}, + ) + assert serializer.is_valid(), serializer.errors + value = serializer.save( + issue_id=issue.id, + property_id=prop.id, + project_id=project.id, + ) + + assert value.value == "Acme Corp" + + @pytest.mark.django_db + def test_create_select_value_invalid_option(self, db, workspace): + """Test that select value must be a valid option""" + project = Project.objects.create( + name="Test Project", + identifier="TEST", + workspace=workspace, + ) + + state = State.objects.create( + name="Open", + project=project, + workspace=workspace, + ) + + issue = Issue.objects.create( + name="Test Issue", + project=project, + workspace=workspace, + state=state, + ) + + prop = IssueProperty.objects.create( + name="Quarter", + property_type="select", + options=[{"value": "Q1"}, {"value": "Q2"}], + project=project, + workspace=workspace, + ) + + serializer = IssuePropertyValueSerializer( + data={ + "property": prop.id, + "value": "Q5", # Not a valid option + }, + context={"project_id": project.id}, + ) + assert not serializer.is_valid() + assert "value" in serializer.errors + + @pytest.mark.django_db + def test_create_number_value_with_string_fails(self, db, workspace): + """Test that number property rejects string value""" + project = Project.objects.create( + name="Test Project", + identifier="TEST", + workspace=workspace, + ) + + state = State.objects.create( + name="Open", + project=project, + workspace=workspace, + ) + + issue = Issue.objects.create( + name="Test Issue", + project=project, + workspace=workspace, + state=state, + ) + + prop = IssueProperty.objects.create( + name="Story Points", + property_type="number", + project=project, + workspace=workspace, + ) + + serializer = IssuePropertyValueSerializer( + data={ + "property": prop.id, + "value": "not a number", + }, + context={"project_id": project.id}, + ) + assert not serializer.is_valid() + assert "value" in serializer.errors + + @pytest.mark.django_db + def test_required_property_without_value_fails(self, db, workspace): + """Test that required property must have a value""" + project = Project.objects.create( + name="Test Project", + identifier="TEST", + workspace=workspace, + ) + + state = State.objects.create( + name="Open", + project=project, + workspace=workspace, + ) + + issue = Issue.objects.create( + name="Test Issue", + project=project, + workspace=workspace, + state=state, + ) + + prop = IssueProperty.objects.create( + name="Required Field", + property_type="text", + is_required=True, + project=project, + workspace=workspace, + ) + + serializer = IssuePropertyValueSerializer( + data={ + "property": prop.id, + "value": None, + }, + context={"project_id": project.id}, + ) + assert not serializer.is_valid() + assert "value" in serializer.errors From 0a47750cb5d256c010545ec536b34f60bcd75a78 Mon Sep 17 00:00:00 2001 From: Vansh Gilhotra Date: Tue, 30 Dec 2025 17:27:18 +0530 Subject: [PATCH 2/2] fix:Implemented the suggestion by cursor bot and coderabbit --- apps/api/plane/app/serializers/issue.py | 87 +- apps/api/plane/app/views/project/property.py | 31 +- .../test_issue_custom_fields_validation.py | 794 ++++++++++++++++++ 3 files changed, 872 insertions(+), 40 deletions(-) create mode 100644 apps/api/plane/tests/contract/app/test_issue_custom_fields_validation.py diff --git a/apps/api/plane/app/serializers/issue.py b/apps/api/plane/app/serializers/issue.py index 5d9230ecab3..6b665579436 100644 --- a/apps/api/plane/app/serializers/issue.py +++ b/apps/api/plane/app/serializers/issue.py @@ -2,7 +2,7 @@ from django.utils import timezone from django.core.validators import URLValidator from django.core.exceptions import ValidationError -from django.db import IntegrityError +from django.db import IntegrityError, transaction # Third Party imports from rest_framework import serializers @@ -271,7 +271,7 @@ def create(self, validated_data): # Handle custom_fields if provided in initial_data custom_fields = self.initial_data.get("custom_fields") if custom_fields: - self._handle_custom_fields( + errors = self._handle_custom_fields( issue=issue, custom_fields=custom_fields, project_id=project_id, @@ -279,6 +279,10 @@ def create(self, validated_data): created_by_id=created_by_id, updated_by_id=updated_by_id, ) + if errors: + # Delete the issue if custom_fields validation fails + issue.delete() + raise serializers.ValidationError({"custom_fields": errors}) return issue @@ -337,7 +341,7 @@ def update(self, instance, validated_data): # Handle custom_fields if provided in initial_data custom_fields = self.initial_data.get("custom_fields") if custom_fields: - self._handle_custom_fields( + errors = self._handle_custom_fields( issue=instance, custom_fields=custom_fields, project_id=project_id, @@ -345,6 +349,8 @@ def update(self, instance, validated_data): created_by_id=created_by_id, updated_by_id=updated_by_id, ) + if errors: + raise serializers.ValidationError({"custom_fields": errors}) # Time updation occues even when other related models are updated instance.updated_at = timezone.now() @@ -354,9 +360,10 @@ def _handle_custom_fields(self, issue, custom_fields, project_id, workspace_id, """ Helper method to handle custom_fields on create/update. custom_fields format: {"property_key": value, ...} + Returns a dict of validation errors if any, otherwise None. """ if not custom_fields or not isinstance(custom_fields, dict): - return + return None # Get all properties for this project by key properties = IssueProperty.objects.filter( @@ -366,6 +373,11 @@ def _handle_custom_fields(self, issue, custom_fields, project_id, workspace_id, ) property_map = {prop.key: prop for prop in properties} + # Validate all keys exist + invalid_keys = set(custom_fields.keys()) - set(property_map.keys()) + if invalid_keys: + return {"custom_fields": f"Unknown property keys: {', '.join(invalid_keys)}"} + # Get existing values for this issue existing_values = IssuePropertyValue.objects.filter( issue=issue, @@ -374,42 +386,53 @@ def _handle_custom_fields(self, issue, custom_fields, project_id, workspace_id, ) existing_map = {ev.property.key: ev for ev in existing_values} - values_to_create = [] - values_to_update = [] + # First pass: validate ALL fields before saving ANY + serializers_to_save = [] + validation_errors = {} for key, value in custom_fields.items(): - if key not in property_map: - continue # Skip unknown properties - prop = property_map[key] + # Prepare data for serializer + data = {"value": value, "property": prop.id} + if key in existing_map: - # Update existing value - existing_value = existing_map[key] - existing_value.value = value - existing_value.updated_by_id = updated_by_id - values_to_update.append(existing_value) + # Update existing + serializer = IssuePropertyValueSerializer( + instance=existing_map[key], + data=data, + context={"project_id": project_id}, + partial=True, + ) else: - # Create new value - values_to_create.append( - IssuePropertyValue( - issue=issue, - property=prop, - value=value, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) + # Create new + serializer = IssuePropertyValueSerializer( + data=data, + context={"project_id": project_id}, ) - # Bulk create new values - if values_to_create: - IssuePropertyValue.objects.bulk_create(values_to_create, batch_size=10) + if serializer.is_valid(): + serializers_to_save.append((key, serializer, prop.id)) + else: + validation_errors[key] = serializer.errors + + # If any validation failed, return errors without saving anything + if validation_errors: + return validation_errors + + # Second pass: save all validated serializers in a transaction + with transaction.atomic(): + for key, serializer, prop_id in serializers_to_save: + serializer.save( + issue_id=issue.id, + property_id=prop_id, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) - # Bulk update existing values - if values_to_update: - IssuePropertyValue.objects.bulk_update(values_to_update, ["value", "updated_by_id"], batch_size=10) + return None class IssueActivitySerializer(BaseSerializer): @@ -1022,9 +1045,11 @@ def to_representation(self, instance): data = super().to_representation(instance) # Add custom_fields - fetch all property values for this issue + # Filter out values where the property itself has been soft-deleted property_values = IssuePropertyValue.objects.filter( issue_id=instance.id, deleted_at__isnull=True, + property__deleted_at__isnull=True, ).select_related("property") # Build custom_fields dict keyed by property key diff --git a/apps/api/plane/app/views/project/property.py b/apps/api/plane/app/views/project/property.py index fb956a57685..5c18ea864d3 100644 --- a/apps/api/plane/app/views/project/property.py +++ b/apps/api/plane/app/views/project/property.py @@ -1,5 +1,5 @@ # Django imports -from django.db import IntegrityError +from django.db import IntegrityError, transaction from django.db.models import Prefetch # Third Party imports @@ -181,6 +181,7 @@ def create(self, request, slug, project_id, issue_id): issue = Issue.objects.get( id=issue_id, project_id=project_id, + deleted_at__isnull=True, ) except Issue.DoesNotExist: return Response( @@ -257,6 +258,7 @@ def post(self, request, slug, project_id, issue_id): issue = Issue.objects.get( id=issue_id, project_id=project_id, + deleted_at__isnull=True, ) except Issue.DoesNotExist: return Response( @@ -288,7 +290,8 @@ def post(self, request, slug, project_id, issue_id): ) existing_map = {ev.property.key: ev for ev in existing_values} - results = [] + # First pass: validate ALL fields before saving ANY + serializers_to_save = [] errors = [] for key, value in custom_fields.items(): @@ -313,21 +316,28 @@ def post(self, request, slug, project_id, issue_id): ) if serializer.is_valid(): - serializer.save( - issue_id=issue_id, - property_id=prop.id, - project_id=project_id, - ) - results.append({key: serializer.data}) + serializers_to_save.append((key, serializer, prop.id)) else: errors.append({key: serializer.errors}) + # If any validation failed, return errors without saving anything if errors: return Response( - {"results": results, "errors": errors}, + {"errors": errors}, status=status.HTTP_400_BAD_REQUEST, ) + # Second pass: save all validated serializers in a transaction + results = [] + with transaction.atomic(): + for key, serializer, prop_id in serializers_to_save: + serializer.save( + issue_id=issue_id, + property_id=prop_id, + project_id=project_id, + ) + results.append({key: serializer.data}) + return Response({"results": results}, status=status.HTTP_200_OK) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) @@ -341,6 +351,7 @@ def get(self, request, slug, project_id, issue_id): issue = Issue.objects.get( id=issue_id, project_id=project_id, + deleted_at__isnull=True, ) except Issue.DoesNotExist: return Response( @@ -349,9 +360,11 @@ def get(self, request, slug, project_id, issue_id): ) # Get all property values for this issue + # Filter out values where the property itself has been soft-deleted property_values = IssuePropertyValue.objects.filter( issue_id=issue_id, deleted_at__isnull=True, + property__deleted_at__isnull=True, ).select_related("property") # Build flat response keyed by property key diff --git a/apps/api/plane/tests/contract/app/test_issue_custom_fields_validation.py b/apps/api/plane/tests/contract/app/test_issue_custom_fields_validation.py new file mode 100644 index 00000000000..9e15ea06ff1 --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_issue_custom_fields_validation.py @@ -0,0 +1,794 @@ +""" +Integration tests for Issue API custom_fields validation. + +Tests that the Issue create/update API endpoints properly validate +custom_fields using IssuePropertyValueSerializer, ensuring type safety +and required field enforcement. +""" +import pytest +from django.urls import reverse +from rest_framework import status + +from plane.db.models import ( + Project, + IssueProperty, + IssuePropertyValue, + Issue, + State, + ProjectMember, +) + + +@pytest.mark.contract +class TestIssueCustomFieldsValidation: + """Test that Issue API validates custom_fields correctly""" + + @pytest.fixture + def project_with_member(self, db, workspace, create_user): + """Create a project with the test user as admin member""" + project = Project.objects.create( + name="Test Project", + identifier="TEST", + workspace=workspace, + ) + ProjectMember.objects.create( + project=project, + member=create_user, + role=20, # Admin + is_active=True, + ) + return project + + @pytest.fixture + def default_state(self, db, workspace, project_with_member): + """Create a default state for issues""" + return State.objects.create( + name="Open", + project=project_with_member, + workspace=workspace, + ) + + @pytest.fixture + def properties(self, db, workspace, project_with_member, create_user): + """Create various property types for testing""" + return { + "story_points": IssueProperty.objects.create( + name="Story Points", + property_type="number", + project=project_with_member, + workspace=workspace, + created_by=create_user, + ), + "is_urgent": IssueProperty.objects.create( + name="Is Urgent", + property_type="boolean", + project=project_with_member, + workspace=workspace, + created_by=create_user, + ), + "quarter": IssueProperty.objects.create( + name="Quarter", + property_type="select", + options=[ + {"value": "Q1", "color": "#FF0000"}, + {"value": "Q2", "color": "#00FF00"}, + {"value": "Q3", "color": "#0000FF"}, + {"value": "Q4", "color": "#FFFF00"}, + ], + project=project_with_member, + workspace=workspace, + created_by=create_user, + ), + "tags": IssueProperty.objects.create( + name="Tags", + property_type="multi_select", + options=[ + {"value": "bug", "color": "#FF0000"}, + {"value": "feature", "color": "#00FF00"}, + {"value": "docs", "color": "#0000FF"}, + ], + project=project_with_member, + workspace=workspace, + created_by=create_user, + ), + "required_field": IssueProperty.objects.create( + name="Required Field", + property_type="text", + is_required=True, + project=project_with_member, + workspace=workspace, + created_by=create_user, + ), + "due_date": IssueProperty.objects.create( + name="Due Date", + property_type="date", + project=project_with_member, + workspace=workspace, + created_by=create_user, + ), + } + + @pytest.mark.django_db + def test_create_issue_with_valid_custom_fields( + self, session_client, workspace, project_with_member, default_state, properties + ): + """Test creating an issue with valid custom_fields values""" + url = f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}/issues/" + data = { + "name": "Test Issue", + "state_id": str(default_state.id), + "custom_fields": { + "story_points": 5, + "is_urgent": True, + "quarter": "Q1", + "tags": ["bug", "feature"], + "required_field": "Some value", + "due_date": "2024-12-31", + }, + } + response = session_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_201_CREATED + + # Verify custom fields were saved + issue_id = response.data["id"] + issue = Issue.objects.get(id=issue_id) + + values = IssuePropertyValue.objects.filter(issue=issue) + assert values.count() == 6 + + @pytest.mark.django_db + def test_create_issue_rejects_string_for_number_field( + self, session_client, workspace, project_with_member, default_state, properties + ): + """Test that string values are rejected for number type fields""" + url = f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}/issues/" + data = { + "name": "Test Issue", + "state_id": str(default_state.id), + "custom_fields": { + "story_points": "not a number", # Invalid: string for number field + }, + } + response = session_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "custom_fields" in response.data + assert "story_points" in response.data["custom_fields"] + assert "number" in str(response.data["custom_fields"]["story_points"]).lower() + + # Verify no issue was created + assert Issue.objects.count() == 0 + + @pytest.mark.django_db + def test_create_issue_rejects_non_boolean_for_boolean_field( + self, session_client, workspace, project_with_member, default_state, properties + ): + """Test that non-boolean values are rejected for boolean type fields""" + url = f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}/issues/" + data = { + "name": "Test Issue", + "state_id": str(default_state.id), + "custom_fields": { + "is_urgent": "yes", # Invalid: string for boolean field + }, + } + response = session_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "custom_fields" in response.data + assert "is_urgent" in response.data["custom_fields"] + assert "boolean" in str(response.data["custom_fields"]["is_urgent"]).lower() + + # Verify no issue was created + assert Issue.objects.count() == 0 + + @pytest.mark.django_db + def test_create_issue_rejects_invalid_select_option( + self, session_client, workspace, project_with_member, default_state, properties + ): + """Test that invalid select options are rejected""" + url = f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}/issues/" + data = { + "name": "Test Issue", + "state_id": str(default_state.id), + "custom_fields": { + "quarter": "Q99", # Invalid: not in options + }, + } + response = session_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "custom_fields" in response.data + assert "quarter" in response.data["custom_fields"] + assert "valid option" in str(response.data["custom_fields"]["quarter"]).lower() + + # Verify no issue was created + assert Issue.objects.count() == 0 + + @pytest.mark.django_db + def test_create_issue_rejects_invalid_multi_select_option( + self, session_client, workspace, project_with_member, default_state, properties + ): + """Test that invalid multi_select options are rejected""" + url = f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}/issues/" + data = { + "name": "Test Issue", + "state_id": str(default_state.id), + "custom_fields": { + "tags": ["bug", "invalid_tag"], # Invalid: invalid_tag not in options + }, + } + response = session_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "custom_fields" in response.data + assert "tags" in response.data["custom_fields"] + assert "valid option" in str(response.data["custom_fields"]["tags"]).lower() + + # Verify no issue was created + assert Issue.objects.count() == 0 + + @pytest.mark.django_db + def test_create_issue_rejects_non_list_for_multi_select( + self, session_client, workspace, project_with_member, default_state, properties + ): + """Test that non-list values are rejected for multi_select fields""" + url = f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}/issues/" + data = { + "name": "Test Issue", + "state_id": str(default_state.id), + "custom_fields": { + "tags": "bug", # Invalid: string instead of list + }, + } + response = session_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "custom_fields" in response.data + assert "tags" in response.data["custom_fields"] + assert "list" in str(response.data["custom_fields"]["tags"]).lower() + + # Verify no issue was created + assert Issue.objects.count() == 0 + + @pytest.mark.django_db + def test_create_issue_rejects_null_for_required_field( + self, session_client, workspace, project_with_member, default_state, properties + ): + """Test that null values are rejected for required fields""" + url = f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}/issues/" + data = { + "name": "Test Issue", + "state_id": str(default_state.id), + "custom_fields": { + "required_field": None, # Invalid: null for required field + }, + } + response = session_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "custom_fields" in response.data + assert "required_field" in response.data["custom_fields"] + assert "required" in str(response.data["custom_fields"]["required_field"]).lower() + + # Verify no issue was created + assert Issue.objects.count() == 0 + + @pytest.mark.django_db + def test_create_issue_rejects_unknown_property_key( + self, session_client, workspace, project_with_member, default_state, properties + ): + """Test that unknown property keys are rejected""" + url = f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}/issues/" + data = { + "name": "Test Issue", + "state_id": str(default_state.id), + "custom_fields": { + "unknown_field": "value", # Invalid: property doesn't exist + }, + } + response = session_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "custom_fields" in response.data + assert "unknown" in str(response.data["custom_fields"]).lower() + + # Verify no issue was created + assert Issue.objects.count() == 0 + + @pytest.mark.django_db + def test_update_issue_with_valid_custom_fields( + self, session_client, workspace, project_with_member, default_state, properties + ): + """Test updating an issue with valid custom_fields values""" + # Create issue first + issue = Issue.objects.create( + name="Test Issue", + project=project_with_member, + workspace=workspace, + state=default_state, + ) + + url = f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}/issues/{issue.id}/" + data = { + "custom_fields": { + "story_points": 8, + "is_urgent": False, + }, + } + response = session_client.patch(url, data, format="json") + + assert response.status_code == status.HTTP_200_OK + + # Verify custom fields were saved + values = IssuePropertyValue.objects.filter(issue=issue) + assert values.count() == 2 + + @pytest.mark.django_db + def test_update_issue_rejects_invalid_custom_fields( + self, session_client, workspace, project_with_member, default_state, properties + ): + """Test updating an issue with invalid custom_fields is rejected""" + # Create issue first + issue = Issue.objects.create( + name="Test Issue", + project=project_with_member, + workspace=workspace, + state=default_state, + ) + + url = f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}/issues/{issue.id}/" + data = { + "custom_fields": { + "story_points": "invalid", # Invalid: string for number field + }, + } + response = session_client.patch(url, data, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "custom_fields" in response.data + assert "story_points" in response.data["custom_fields"] + + # Verify no custom fields were saved + values = IssuePropertyValue.objects.filter(issue=issue) + assert values.count() == 0 + + @pytest.mark.django_db + def test_update_existing_custom_field_rejects_invalid_value( + self, session_client, workspace, project_with_member, default_state, properties + ): + """Test updating an existing custom field value with invalid data is rejected""" + # Create issue with valid custom field + issue = Issue.objects.create( + name="Test Issue", + project=project_with_member, + workspace=workspace, + state=default_state, + ) + IssuePropertyValue.objects.create( + issue=issue, + property=properties["story_points"], + value=5, + project=project_with_member, + workspace=workspace, + ) + + url = f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}/issues/{issue.id}/" + data = { + "custom_fields": { + "story_points": "invalid", # Try to update with invalid value + }, + } + response = session_client.patch(url, data, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "custom_fields" in response.data + assert "story_points" in response.data["custom_fields"] + + # Verify original value is unchanged + value = IssuePropertyValue.objects.get(issue=issue, property=properties["story_points"]) + assert value.value == 5 + + @pytest.mark.django_db + def test_create_issue_with_multiple_validation_errors( + self, session_client, workspace, project_with_member, default_state, properties + ): + """Test that multiple validation errors are returned together""" + url = f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}/issues/" + data = { + "name": "Test Issue", + "state_id": str(default_state.id), + "custom_fields": { + "story_points": "not a number", # Invalid + "is_urgent": "yes", # Invalid + "quarter": "Q99", # Invalid + }, + } + response = session_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "custom_fields" in response.data + + # All three errors should be present + assert "story_points" in response.data["custom_fields"] + assert "is_urgent" in response.data["custom_fields"] + assert "quarter" in response.data["custom_fields"] + + # Verify no issue was created + assert Issue.objects.count() == 0 + + @pytest.mark.django_db + def test_create_issue_atomic_no_partial_save_on_error( + self, session_client, workspace, project_with_member, default_state, properties + ): + """Test that when validation fails, NO custom fields are saved (atomic behavior)""" + url = f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}/issues/" + data = { + "name": "Test Issue", + "state_id": str(default_state.id), + "custom_fields": { + "story_points": 5, # Valid + "is_urgent": True, # Valid + "quarter": "Q99", # Invalid - will cause failure + }, + } + response = session_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # Verify NO custom fields were saved (atomic behavior) + assert IssuePropertyValue.objects.count() == 0 + + # Verify no issue was created + assert Issue.objects.count() == 0 + + @pytest.mark.django_db + def test_update_issue_atomic_no_partial_save_on_error( + self, session_client, workspace, project_with_member, default_state, properties + ): + """Test that when update validation fails, NO custom fields are saved (atomic behavior)""" + # Create issue first + issue = Issue.objects.create( + name="Test Issue", + project=project_with_member, + workspace=workspace, + state=default_state, + ) + + # Add one valid custom field + IssuePropertyValue.objects.create( + issue=issue, + property=properties["story_points"], + value=3, + project=project_with_member, + workspace=workspace, + ) + + url = f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}/issues/{issue.id}/" + data = { + "custom_fields": { + "story_points": 8, # Valid - would update existing + "is_urgent": True, # Valid - would create new + "quarter": "Q99", # Invalid - will cause failure + }, + } + response = session_client.patch(url, data, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # Verify original value is unchanged (atomic rollback) + value = IssuePropertyValue.objects.get(issue=issue, property=properties["story_points"]) + assert value.value == 3 # Not updated to 8 + + # Verify no new values were created + assert IssuePropertyValue.objects.filter(issue=issue).count() == 1 + + @pytest.mark.django_db + def test_bulk_endpoint_atomic_no_partial_save_on_error( + self, session_client, workspace, project_with_member, default_state, properties + ): + """Test that BulkIssuePropertyValueEndpoint doesn't partially save on validation error""" + # Create issue first + issue = Issue.objects.create( + name="Test Issue", + project=project_with_member, + workspace=workspace, + state=default_state, + ) + + url = ( + f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}" + f"/issues/{issue.id}/property-values/" + ) + data = { + "custom_fields": { + "story_points": 5, # Valid + "is_urgent": True, # Valid + "quarter": "Q99", # Invalid - will cause failure + }, + } + response = session_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # Verify NO custom fields were saved (atomic behavior) + assert IssuePropertyValue.objects.filter(issue=issue).count() == 0 + + +@pytest.mark.contract +class TestSoftDeletedPropertyFiltering: + """Test that soft-deleted properties don't appear in API responses""" + + @pytest.fixture + def project_with_member(self, db, workspace, create_user): + """Create a project with the test user as admin member""" + project = Project.objects.create( + name="Test Project", + identifier="TEST", + workspace=workspace, + ) + ProjectMember.objects.create( + project=project, + member=create_user, + role=20, # Admin + is_active=True, + ) + return project + + @pytest.fixture + def default_state(self, db, workspace, project_with_member): + """Create a default state for issues""" + return State.objects.create( + name="Open", + project=project_with_member, + workspace=workspace, + ) + + @pytest.fixture + def properties(self, db, workspace, project_with_member, create_user): + """Create test properties""" + return { + "active_field": IssueProperty.objects.create( + name="Active Field", + property_type="text", + project=project_with_member, + workspace=workspace, + created_by=create_user, + ), + "deleted_field": IssueProperty.objects.create( + name="Deleted Field", + property_type="text", + project=project_with_member, + workspace=workspace, + created_by=create_user, + ), + } + + @pytest.mark.django_db + def test_bulk_endpoint_hides_soft_deleted_property_values( + self, session_client, workspace, project_with_member, default_state, properties, create_user + ): + """Test that BulkIssuePropertyValueEndpoint.get doesn't return values for soft-deleted properties""" + # Create issue + issue = Issue.objects.create( + name="Test Issue", + project=project_with_member, + workspace=workspace, + state=default_state, + ) + + # Create values for both properties + IssuePropertyValue.objects.create( + issue=issue, + property=properties["active_field"], + value="Active Value", + project=project_with_member, + workspace=workspace, + ) + IssuePropertyValue.objects.create( + issue=issue, + property=properties["deleted_field"], + value="Should Not Appear", + project=project_with_member, + workspace=workspace, + ) + + # Soft-delete the property (not the value) + from django.utils import timezone + properties["deleted_field"].deleted_at = timezone.now() + properties["deleted_field"].save() + + # Get custom fields via bulk endpoint + url = ( + f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}" + f"/issues/{issue.id}/custom-fields/" + ) + response = session_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert "custom_fields" in response.data + + # Only active property should appear + assert "active_field" in response.data["custom_fields"] + assert response.data["custom_fields"]["active_field"] == "Active Value" + + # Deleted property should NOT appear + assert "deleted_field" not in response.data["custom_fields"] + + # Verify the value still exists in database (soft-delete only affects property) + assert IssuePropertyValue.objects.filter( + issue=issue, + property=properties["deleted_field"], + deleted_at__isnull=True + ).exists() + + @pytest.mark.django_db + def test_issue_detail_serializer_hides_soft_deleted_property_values( + self, session_client, workspace, project_with_member, default_state, properties, create_user + ): + """Test that IssueDetailSerializer doesn't return values for soft-deleted properties""" + # Create issue + issue = Issue.objects.create( + name="Test Issue", + project=project_with_member, + workspace=workspace, + state=default_state, + ) + + # Create values for both properties + IssuePropertyValue.objects.create( + issue=issue, + property=properties["active_field"], + value="Active Value", + project=project_with_member, + workspace=workspace, + ) + IssuePropertyValue.objects.create( + issue=issue, + property=properties["deleted_field"], + value="Should Not Appear", + project=project_with_member, + workspace=workspace, + ) + + # Soft-delete the property (not the value) + from django.utils import timezone + properties["deleted_field"].deleted_at = timezone.now() + properties["deleted_field"].save() + + # Get issue detail + url = f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}/issues/{issue.id}/" + response = session_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert "custom_fields" in response.data + + # Only active property should appear + assert "active_field" in response.data["custom_fields"] + assert response.data["custom_fields"]["active_field"] == "Active Value" + + # Deleted property should NOT appear + assert "deleted_field" not in response.data["custom_fields"] + + @pytest.mark.django_db + def test_soft_deleted_value_is_hidden_even_with_active_property( + self, session_client, workspace, project_with_member, default_state, properties, create_user + ): + """Test that soft-deleted values are hidden even when property is active""" + # Create issue + issue = Issue.objects.create( + name="Test Issue", + project=project_with_member, + workspace=workspace, + state=default_state, + ) + + # Create value + value = IssuePropertyValue.objects.create( + issue=issue, + property=properties["active_field"], + value="Should Not Appear", + project=project_with_member, + workspace=workspace, + ) + + # Soft-delete the value (not the property) + from django.utils import timezone + value.deleted_at = timezone.now() + value.save() + + # Get custom fields via bulk endpoint + url = ( + f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}" + f"/issues/{issue.id}/custom-fields/" + ) + response = session_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert "custom_fields" in response.data + + # Soft-deleted value should NOT appear + assert "active_field" not in response.data["custom_fields"] + + @pytest.mark.django_db + def test_both_property_and_value_deleted_is_hidden( + self, session_client, workspace, project_with_member, default_state, properties, create_user + ): + """Test that values are hidden when both property AND value are soft-deleted""" + # Create issue + issue = Issue.objects.create( + name="Test Issue", + project=project_with_member, + workspace=workspace, + state=default_state, + ) + + # Create value + value = IssuePropertyValue.objects.create( + issue=issue, + property=properties["deleted_field"], + value="Should Not Appear", + project=project_with_member, + workspace=workspace, + ) + + # Soft-delete both property and value + from django.utils import timezone + now = timezone.now() + properties["deleted_field"].deleted_at = now + properties["deleted_field"].save() + value.deleted_at = now + value.save() + + # Get custom fields via bulk endpoint + url = ( + f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}" + f"/issues/{issue.id}/custom-fields/" + ) + response = session_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert "custom_fields" in response.data + + # Nothing should appear + assert len(response.data["custom_fields"]) == 0 + + @pytest.mark.django_db + def test_soft_deleted_issue_returns_404( + self, session_client, workspace, project_with_member, default_state, properties, create_user + ): + """Test that operations on soft-deleted issues return 404""" + # Create issue + issue = Issue.objects.create( + name="Test Issue", + project=project_with_member, + workspace=workspace, + state=default_state, + ) + + # Create a property value + IssuePropertyValue.objects.create( + issue=issue, + property=properties["active_field"], + value="Some Value", + project=project_with_member, + workspace=workspace, + ) + + # Soft-delete the issue + from django.utils import timezone + issue.deleted_at = timezone.now() + issue.save() + + # Try to get custom fields for soft-deleted issue + url = ( + f"/api/v1/workspaces/{workspace.slug}/projects/{project_with_member.id}" + f"/issues/{issue.id}/custom-fields/" + ) + response = session_client.get(url) + + # Should return 404, not the custom fields + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "error" in response.data + assert "not found" in response.data["error"].lower()