Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions apps/api/plane/api/serializers/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,11 @@ class Meta:
exclude = ["description_json", "description_stripped"]

def validate(self, data):
if (
data.get("start_date", None) is not None
and data.get("target_date", None) is not None
and data.get("start_date", None) > data.get("target_date", None)
):
raise serializers.ValidationError("Start date cannot exceed target date")
if "start_date" in data or "target_date" in data:
start_date = data.get("start_date", getattr(self.instance, "start_date", None))
target_date = data.get("target_date", getattr(self.instance, "target_date", None))
if start_date is not None and target_date is not None and start_date > target_date:
raise serializers.ValidationError("Start date cannot exceed target date")

try:
if data.get("description_html", None) is not None:
Expand Down
11 changes: 5 additions & 6 deletions apps/api/plane/app/serializers/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,12 +125,11 @@ def validate(self, attrs):
allow_triage = self.context.get("allow_triage_state", False)
state_manager = State.triage_objects if allow_triage else State.objects

if (
attrs.get("start_date", None) is not None
and attrs.get("target_date", None) is not None
and attrs.get("start_date", None) > attrs.get("target_date", None)
):
raise serializers.ValidationError("Start date cannot exceed target date")
if "start_date" in attrs or "target_date" in attrs:
start_date = attrs.get("start_date", getattr(self.instance, "start_date", None))
target_date = attrs.get("target_date", getattr(self.instance, "target_date", None))
if start_date is not None and target_date is not None and start_date > target_date:
raise serializers.ValidationError("Start date cannot exceed target date")

# Validate description content for security
if "description_html" in attrs and attrs["description_html"]:
Expand Down
89 changes: 89 additions & 0 deletions apps/api/plane/tests/contract/api/test_issue_date_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

from datetime import date
from unittest import mock

import pytest
from rest_framework import status

from plane.db.models import Issue, Project, ProjectMember, State


@pytest.fixture
def scheduled_issue(db, workspace, create_user):
project = Project.objects.create(name="Test Project", identifier="TP", workspace=workspace)
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
state = State.objects.create(name="Todo", project=project, workspace=workspace, group="backlog", default=True)
return Issue.objects.create(
name="Scheduled work item",
project=project,
workspace=workspace,
state=state,
created_by=create_user,
start_date=date(2026, 1, 10),
target_date=date(2026, 1, 20),
)


@pytest.fixture(params=["public-api", "app-api"])
def endpoint(request, workspace, scheduled_issue):
project_path = f"workspaces/{workspace.slug}/projects/{scheduled_issue.project_id}"
if request.param == "public-api":
client = request.getfixturevalue("api_key_client")
url = f"/api/v1/{project_path}/work-items/{scheduled_issue.id}/"
success_status = status.HTTP_200_OK
else:
client = request.getfixturevalue("api_client")
client.force_login(request.getfixturevalue("create_user"))
url = f"/api/{project_path}/issues/{scheduled_issue.id}/"
success_status = status.HTTP_204_NO_CONTENT
return client, url, success_status


@pytest.fixture(autouse=True)
def mock_deferred_tasks():
"""Keep activity/webhook delivery outside these request and persistence tests."""
with (
mock.patch("plane.api.views.issue.issue_activity.delay"),
mock.patch("plane.api.views.issue.model_activity.delay"),
mock.patch("plane.app.views.issue.base.issue_description_version_task.delay"),
):
yield


@pytest.mark.contract
@pytest.mark.django_db
class TestIssueDateValidation:
@pytest.mark.parametrize("dates", [{"start_date": "2026-01-21"}, {"target_date": "2026-01-09"}])
def test_invalid_partial_date_update_does_not_change_work_item(self, endpoint, scheduled_issue, dates):
client, url, _ = endpoint
updated_at = scheduled_issue.updated_at

response = client.patch(url, {"name": "Should not be saved", **dates}, format="json")

assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data == {"non_field_errors": ["Start date cannot exceed target date"]}
scheduled_issue.refresh_from_db()
assert scheduled_issue.name == "Scheduled work item"
assert scheduled_issue.start_date == date(2026, 1, 10)
assert scheduled_issue.target_date == date(2026, 1, 20)
assert scheduled_issue.updated_at == updated_at

def test_valid_partial_date_update_and_clearing_date_are_persisted(self, endpoint, scheduled_issue):
client, url, success_status = endpoint

response = client.patch(url, {"start_date": "2026-01-20"}, format="json")

assert response.status_code == success_status
scheduled_issue.refresh_from_db()
assert scheduled_issue.start_date == date(2026, 1, 20)
assert scheduled_issue.target_date == date(2026, 1, 20)

response = client.patch(url, {"target_date": None}, format="json")

assert response.status_code == success_status
scheduled_issue.refresh_from_db()
assert scheduled_issue.start_date == date(2026, 1, 20)
assert scheduled_issue.target_date is None
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

from datetime import date

import pytest

from plane.api.serializers.issue import IssueSerializer
from plane.app.serializers.issue import IssueCreateSerializer
from plane.db.models import Issue


@pytest.fixture(params=[IssueSerializer, IssueCreateSerializer], ids=["public-api", "app-api"])
def serializer_class(request):
return request.param


@pytest.mark.unit
class TestIssueDateValidation:
@pytest.mark.parametrize(
"payload",
[
{"start_date": "2026-01-21"},
{"target_date": "2026-01-09"},
],
)
def test_partial_update_rejects_invalid_range_against_existing_date(self, serializer_class, payload):
issue = Issue(name="Scheduled work item", start_date=date(2026, 1, 10), target_date=date(2026, 1, 20))
serializer = serializer_class(issue, data=payload, partial=True)

assert not serializer.is_valid()
assert serializer.errors["non_field_errors"] == ["Start date cannot exceed target date"]

@pytest.mark.parametrize(
"payload",
[
{"start_date": "2026-01-15"},
{"target_date": "2026-01-15"},
{"start_date": "2026-01-20"},
{"target_date": "2026-01-10"},
{"start_date": None},
{"target_date": None},
{"start_date": None, "target_date": None},
{"start_date": "2026-02-01", "target_date": "2026-02-10"},
],
)
def test_partial_update_accepts_valid_ranges_and_clearing_dates(self, serializer_class, payload):
issue = Issue(name="Scheduled work item", start_date=date(2026, 1, 10), target_date=date(2026, 1, 20))
serializer = serializer_class(issue, data=payload, partial=True)

assert serializer.is_valid(), serializer.errors
for field, value in payload.items():
assert serializer.validated_data[field] == (date.fromisoformat(value) if value else None)

@pytest.mark.parametrize("field", ["start_date", "target_date"])
def test_partial_update_accepts_date_when_other_date_is_unset(self, serializer_class, field):
issue = Issue(name="Unscheduled work item")
serializer = serializer_class(issue, data={field: "2026-01-10"}, partial=True)

assert serializer.is_valid(), serializer.errors

def test_unrelated_update_does_not_revalidate_existing_dates(self, serializer_class):
issue = Issue(name="Legacy work item", start_date=date(2026, 1, 20), target_date=date(2026, 1, 10))
serializer = serializer_class(issue, data={"name": "Renamed work item"}, partial=True)

assert serializer.is_valid(), serializer.errors

@pytest.mark.parametrize("partial", [False, True], ids=["create", "partial-update"])
def test_rejects_invalid_range_with_both_dates_supplied(self, serializer_class, partial):
serializer = serializer_class(
instance=Issue(name="Existing work item") if partial else None,
data={"name": "Scheduled work item", "start_date": "2026-01-20", "target_date": "2026-01-10"},
partial=partial,
)

assert not serializer.is_valid()
assert serializer.errors["non_field_errors"] == ["Start date cannot exceed target date"]

@pytest.mark.parametrize(
"dates",
[
{},
{"start_date": "2026-01-10"},
{"target_date": "2026-01-20"},
{"start_date": "2026-01-10", "target_date": "2026-01-20"},
{"start_date": "2026-01-10", "target_date": "2026-01-10"},
],
)
def test_create_accepts_valid_dates(self, serializer_class, dates):
serializer = serializer_class(data={"name": "Scheduled work item", **dates})

assert serializer.is_valid(), serializer.errors