[WEB-8477] fix: created_at/updated_at filters return no work items (#9513)

Bug 5: filtering the work item list by a creation/update date returned an empty
list. created_at and updated_at are DateTimeFields, but the UI sends a bare
calendar date, and the filterset only exposed `exact` and `range` lookups:

- {"created_at__exact": "2026-07-30"} was coerced to 2026-07-30 00:00:00, so it
  matched only rows stamped exactly midnight — effectively never.
- {"created_at__range": "2026-07-28,2026-07-30"} capped the upper bound at
  2026-07-30 00:00:00, silently dropping everything created during that final
  day (the range only "worked" if you overshot the end date by one day).

Compare the date component instead (`date` / `date__range` via a CSV-parsing
DateCSVRangeFilter), so a calendar date means the whole day and both range
bounds are inclusive. The UI's existing query format is unchanged.

Verified against a local canary build: the exact requests from the bug report
now return 4 and 4 (previously 0 and 0). Adds unit coverage; 6 of the 7 new
tests fail without this change.

Note: `__date` is evaluated in the active timezone, which TimezoneMixin takes
from the user's profile (user_timezone) rather than the browser's timezone, so a
profile/browser timezone mismatch can still shift results by a day. Tracked
separately — not addressed here.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Manish Gupta
2026-07-30 19:51:16 +05:30
committed by GitHub
parent 7564480cf7
commit e496b24f27
2 changed files with 136 additions and 0 deletions

View File

@@ -0,0 +1,105 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Regression tests for created_at / updated_at rich-filter lookups (WEB-8477 Bug 5).
``created_at`` and ``updated_at`` are DateTimeFields, but the UI sends a bare calendar
date. With the default ``exact`` / ``range`` lookups django-filter coerced those dates to
midnight, so:
* ``created_at__exact=<date>`` matched only rows stamped exactly 00:00:00 -> always empty.
* ``created_at__range=<a>,<b>`` capped the upper bound at ``b`` 00:00:00 -> silently
dropped every row created during the final day of the range.
Both surfaced as "filter returns no work items". The filters now compare the date
component (``date`` / ``date__range``) so a calendar date means the whole day.
"""
import datetime
import pytest
from plane.db.models import Issue, Project, ProjectMember
from plane.utils.filters.filterset import IssueFilterSet
@pytest.fixture
def project(db, workspace, create_user):
project = Project.objects.create(
name="Filter Project",
identifier="FP",
workspace=workspace,
created_by=create_user,
)
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
return project
def _issue_at(project, create_user, name, created, updated):
"""Create an issue then force created_at/updated_at (both are auto-managed)."""
issue = Issue.objects.create(name=name, project=project, workspace=project.workspace, created_by=create_user)
Issue.objects.filter(pk=issue.pk).update(created_at=created, updated_at=updated)
issue.refresh_from_db()
return issue
@pytest.fixture
def issues(db, project, create_user):
tz = datetime.timezone.utc
return {
# created mid-day, NOT at midnight — the case `exact` used to miss
"midday": _issue_at(
project,
create_user,
"midday",
datetime.datetime(2026, 7, 30, 13, 45, tzinfo=tz),
datetime.datetime(2026, 7, 30, 13, 45, tzinfo=tz),
),
# created on an earlier day, but updated on 2026-07-30
"old": _issue_at(
project,
create_user,
"old",
datetime.datetime(2020, 1, 1, 9, 0, tzinfo=tz),
datetime.datetime(2026, 7, 30, 10, 0, tzinfo=tz),
),
}
def _filter(project, data):
qs = Issue.objects.filter(project=project)
fs = IssueFilterSet(data=data, queryset=qs)
assert fs.is_valid(), fs.errors
return set(fs.qs.values_list("name", flat=True))
@pytest.mark.unit
class TestCreatedAtFilter:
def test_exact_matches_whole_day_not_just_midnight(self, project, issues):
# Previously returned nothing because 13:45 != 00:00:00.
assert _filter(project, {"created_at__exact": "2026-07-30"}) == {"midday"}
def test_exact_excludes_other_days(self, project, issues):
assert _filter(project, {"created_at__exact": "2020-01-01"}) == {"old"}
assert _filter(project, {"created_at__exact": "2019-05-05"}) == set()
def test_range_includes_the_final_day(self, project, issues):
# Previously returned nothing: the upper bound became 2026-07-30 00:00:00.
assert _filter(project, {"created_at__range": "2026-07-28,2026-07-30"}) == {"midday"}
def test_single_day_range(self, project, issues):
assert _filter(project, {"created_at__range": "2026-07-30,2026-07-30"}) == {"midday"}
def test_range_excludes_outside_days(self, project, issues):
assert _filter(project, {"created_at__range": "2019-01-01,2019-12-31"}) == set()
@pytest.mark.unit
class TestUpdatedAtFilter:
def test_exact_uses_updated_at_not_created_at(self, project, issues):
# "old" was created in 2020 but updated on 2026-07-30, so it must be included.
assert _filter(project, {"updated_at__exact": "2026-07-30"}) == {"midday", "old"}
def test_range_includes_the_final_day(self, project, issues):
assert _filter(project, {"updated_at__range": "2026-07-28,2026-07-30"}) == {"midday", "old"}

View File

@@ -19,6 +19,17 @@ class CharInFilter(filters.BaseInFilter, filters.CharFilter):
pass
class DateCSVRangeFilter(filters.BaseCSVFilter, filters.DateFilter):
"""Comma-separated date range ("YYYY-MM-DD,YYYY-MM-DD") for DateTimeField columns.
Parses the CSV value the UI sends into a list of dates so it can be paired with a
``date__range`` lookup — comparing the date component, which makes both bounds
inclusive whole days.
"""
pass
class BaseFilterSet(FilterSet):
@classmethod
def get_filters(cls):
@@ -157,6 +168,26 @@ class IssueFilterSet(BaseFilterSet):
subscriber_id = filters.UUIDFilter(method="filter_subscriber_id")
subscriber_id__in = UUIDInFilter(method="filter_subscriber_id_in", lookup_expr="in")
# created_at / updated_at are DateTimeFields, but the UI sends a bare calendar
# date (yyyy-MM-dd, e.g. {"created_at__exact": "2026-07-30"}). An `exact` lookup
# coerces that to midnight, so the filter only matched rows stamped exactly
# 00:00:00 and silently returned an empty list. Compare the date component
# instead so "created on <date>" means the whole day.
# NOTE: `__date` is evaluated in the active timezone, which TimezoneMixin sets
# from the user's profile (user_timezone) — not the browser's timezone. A user
# whose profile timezone differs from their browser can still see off-by-one-day
# results; that mismatch is tracked separately.
# The same applies to ranges: the UI sends "YYYY-MM-DD,YYYY-MM-DD", and a plain
# `range` on a DateTimeField turns the upper bound into that day's midnight, which
# silently dropped every row created during the final day of the range.
created_at = filters.DateFilter(field_name="created_at", lookup_expr="date")
created_at__exact = filters.DateFilter(field_name="created_at", lookup_expr="date")
created_at__range = DateCSVRangeFilter(field_name="created_at", lookup_expr="date__range")
updated_at = filters.DateFilter(field_name="updated_at", lookup_expr="date")
updated_at__exact = filters.DateFilter(field_name="updated_at", lookup_expr="date")
updated_at__range = DateCSVRangeFilter(field_name="updated_at", lookup_expr="date__range")
class Meta:
model = Issue
fields = {