diff --git a/apps/api/plane/app/views/analytic/base.py b/apps/api/plane/app/views/analytic/base.py index a05712c4ecf..3f8f644da7c 100644 --- a/apps/api/plane/app/views/analytic/base.py +++ b/apps/api/plane/app/views/analytic/base.py @@ -6,7 +6,7 @@ from django.db.models import Count, F, Sum, Q from django.db.models.functions import ExtractMonth from django.utils import timezone -from django.db.models.functions import Concat +from django.db.models.functions import Cast, Concat from django.db.models import Case, When, Value, OuterRef, Func from django.db import models @@ -105,7 +105,7 @@ def get(self, request, slug): assignees__avatar_asset__isnull=False, then=Concat( Value("/api/assets/v2/static/"), - "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Cast("assignees__avatar_asset", models.CharField()), Value("/"), ), ), @@ -299,7 +299,7 @@ def get(self, request, slug): created_by__avatar_asset__isnull=False, then=Concat( Value("/api/assets/v2/static/"), - "created_by__avatar_asset", # Assuming avatar_asset has an id or relevant field + Cast("created_by__avatar_asset", models.CharField()), Value("/"), ), ), @@ -330,7 +330,7 @@ def get(self, request, slug): assignees__avatar_asset__isnull=False, then=Concat( Value("/api/assets/v2/static/"), - "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Cast("assignees__avatar_asset", models.CharField()), Value("/"), ), ), @@ -355,7 +355,7 @@ def get(self, request, slug): assignees__avatar_asset__isnull=False, then=Concat( Value("/api/assets/v2/static/"), - "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Cast("assignees__avatar_asset", models.CharField()), Value("/"), ), ), diff --git a/apps/api/plane/app/views/analytic/project_analytics.py b/apps/api/plane/app/views/analytic/project_analytics.py index c8e896716b5..064e556a2cd 100644 --- a/apps/api/plane/app/views/analytic/project_analytics.py +++ b/apps/api/plane/app/views/analytic/project_analytics.py @@ -22,7 +22,7 @@ ) from django.db import models from django.db.models import F, Case, When, Value -from django.db.models.functions import Concat +from django.db.models.functions import Cast, Concat from plane.utils.build_chart import build_analytics_chart from plane.utils.date_utils import ( get_analytics_filters, @@ -141,7 +141,7 @@ def get_work_items_stats(self, project_id, cycle_id=None, module_id=None) -> Dic assignees__avatar_asset__isnull=False, then=Concat( Value("/api/assets/v2/static/"), - "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Cast("assignees__avatar_asset", models.CharField()), Value("/"), ), ), diff --git a/apps/api/plane/app/views/cycle/archive.py b/apps/api/plane/app/views/cycle/archive.py index 3738b336717..772fcedea19 100644 --- a/apps/api/plane/app/views/cycle/archive.py +++ b/apps/api/plane/app/views/cycle/archive.py @@ -380,7 +380,7 @@ def get(self, request, slug, project_id, pk=None): assignees__avatar_asset__isnull=False, then=Concat( Value("/api/assets/v2/static/"), - "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Cast("assignees__avatar_asset", models.CharField()), Value("/"), ), ), @@ -485,7 +485,7 @@ def get(self, request, slug, project_id, pk=None): assignees__avatar_asset__isnull=False, then=Concat( Value("/api/assets/v2/static/"), - "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Cast("assignees__avatar_asset", models.CharField()), Value("/"), ), ), diff --git a/apps/api/plane/app/views/cycle/base.py b/apps/api/plane/app/views/cycle/base.py index 30a5732ce0a..cb10c5d0245 100644 --- a/apps/api/plane/app/views/cycle/base.py +++ b/apps/api/plane/app/views/cycle/base.py @@ -857,7 +857,7 @@ def get(self, request, slug, project_id, cycle_id): assignees__avatar_asset__isnull=False, then=Concat( Value("/api/assets/v2/static/"), - "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Cast("assignees__avatar_asset", models.CharField()), Value("/"), ), ), @@ -954,7 +954,7 @@ def get(self, request, slug, project_id, cycle_id): assignees__avatar_asset__isnull=False, then=Concat( Value("/api/assets/v2/static/"), - "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Cast("assignees__avatar_asset", models.CharField()), Value("/"), ), ), diff --git a/apps/api/plane/app/views/module/archive.py b/apps/api/plane/app/views/module/archive.py index 1f234d79156..36a3ea73995 100644 --- a/apps/api/plane/app/views/module/archive.py +++ b/apps/api/plane/app/views/module/archive.py @@ -339,7 +339,7 @@ def get(self, request, slug, project_id, pk=None): assignees__avatar_asset__isnull=False, then=Concat( Value("/api/assets/v2/static/"), - "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Cast("assignees__avatar_asset", models.CharField()), Value("/"), ), ), @@ -446,7 +446,7 @@ def get(self, request, slug, project_id, pk=None): assignees__avatar_asset__isnull=False, then=Concat( Value("/api/assets/v2/static/"), - "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Cast("assignees__avatar_asset", models.CharField()), Value("/"), ), ), diff --git a/apps/api/plane/app/views/module/base.py b/apps/api/plane/app/views/module/base.py index 97e683f7508..45338255218 100644 --- a/apps/api/plane/app/views/module/base.py +++ b/apps/api/plane/app/views/module/base.py @@ -445,7 +445,7 @@ def retrieve(self, request, slug, project_id, pk): assignees__avatar_asset__isnull=False, then=Concat( Value("/api/assets/v2/static/"), - "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Cast("assignees__avatar_asset", models.CharField()), Value("/"), ), ), @@ -553,7 +553,7 @@ def retrieve(self, request, slug, project_id, pk): assignees__avatar_asset__isnull=False, then=Concat( Value("/api/assets/v2/static/"), - "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Cast("assignees__avatar_asset", models.CharField()), Value("/"), ), ), diff --git a/apps/api/plane/app/views/search/base.py b/apps/api/plane/app/views/search/base.py index 1aff9d6c750..289155b87c6 100644 --- a/apps/api/plane/app/views/search/base.py +++ b/apps/api/plane/app/views/search/base.py @@ -19,7 +19,7 @@ ) from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField -from django.db.models.functions import Coalesce, Concat +from django.db.models.functions import Cast, Coalesce, Concat from django.utils import timezone # Third party imports @@ -342,7 +342,7 @@ def get(self, request, slug): member__avatar_asset__isnull=False, then=Concat( Value("/api/assets/v2/static/"), - "member__avatar_asset", + Cast("member__avatar_asset", CharField()), Value("/"), ), ), @@ -553,7 +553,7 @@ def get(self, request, slug): member__avatar_asset__isnull=False, then=Concat( Value("/api/assets/v2/static/"), - "member__avatar_asset", + Cast("member__avatar_asset", models.CharField()), Value("/"), ), ), diff --git a/apps/api/plane/bgtasks/analytic_plot_export.py b/apps/api/plane/bgtasks/analytic_plot_export.py index 4b0983138be..8f321e6e869 100644 --- a/apps/api/plane/bgtasks/analytic_plot_export.py +++ b/apps/api/plane/bgtasks/analytic_plot_export.py @@ -15,7 +15,7 @@ from django.template.loader import render_to_string from django.db.models import Q, Case, Value, When from django.db import models -from django.db.models.functions import Concat +from django.db.models.functions import Cast, Concat # Module imports from plane.db.models import Issue @@ -103,7 +103,7 @@ def get_assignee_details(slug, filters): assignees__avatar_asset__isnull=False, then=Concat( Value("/api/assets/v2/static/"), - "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Cast("assignees__avatar_asset", models.CharField()), Value("/"), ), ), diff --git a/apps/api/plane/space/utils/grouper.py b/apps/api/plane/space/utils/grouper.py index e5f893bd5b7..4820ec8893a 100644 --- a/apps/api/plane/space/utils/grouper.py +++ b/apps/api/plane/space/utils/grouper.py @@ -6,7 +6,7 @@ from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField from django.db.models import Q, UUIDField, Value, F, Case, When, JSONField, CharField -from django.db.models.functions import Coalesce, JSONObject, Concat +from django.db.models.functions import Cast, Coalesce, JSONObject, Concat from django.db.models import QuerySet from typing import List, Optional, Dict, Any, Union @@ -125,7 +125,7 @@ def issue_on_results( votes__actor__avatar_asset__isnull=False, then=Concat( Value("/api/assets/v2/static/"), - F("votes__actor__avatar_asset"), + Cast("votes__actor__avatar_asset", CharField()), Value("/"), ), ), @@ -159,7 +159,7 @@ def issue_on_results( issue_reactions__actor__avatar_asset__isnull=False, then=Concat( Value("/api/assets/v2/static/"), - F("issue_reactions__actor__avatar_asset"), + Cast("issue_reactions__actor__avatar_asset", CharField()), Value("/"), ), ), diff --git a/apps/api/plane/space/views/issue.py b/apps/api/plane/space/views/issue.py index 9e2187466aa..386d4c84d29 100644 --- a/apps/api/plane/space/views/issue.py +++ b/apps/api/plane/space/views/issue.py @@ -26,7 +26,7 @@ CharField, Subquery, ) -from django.db.models.functions import Concat +from django.db.models.functions import Cast, Concat # Third Party imports from rest_framework.response import Response @@ -667,7 +667,7 @@ def get(self, request, anchor, issue_id): votes__actor__avatar_asset__isnull=False, then=Concat( Value("/api/assets/v2/static/"), - F("votes__actor__avatar_asset"), + Cast("votes__actor__avatar_asset", CharField()), Value("/"), ), ), @@ -713,7 +713,7 @@ def get(self, request, anchor, issue_id): votes__actor__avatar_asset__isnull=False, then=Concat( Value("/api/assets/v2/static/"), - F("votes__actor__avatar_asset"), + Cast("votes__actor__avatar_asset", CharField()), Value("/"), ), ), diff --git a/apps/api/plane/tests/contract/app/test_avatar_url_annotation.py b/apps/api/plane/tests/contract/app/test_avatar_url_annotation.py new file mode 100644 index 00000000000..a1727bd3b17 --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_avatar_url_annotation.py @@ -0,0 +1,213 @@ +# 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 the ``avatar_url`` annotation used by the assignee +distribution / analytics querysets. + +Those querysets build the avatar URL with +``Concat(Value("/api/assets/v2/static/"), __avatar_asset, Value("/"))``. +On Django 5.x ``ConcatPair.as_postgresql`` resolves the output field of each +argument, so concatenating a ``CharField`` with the raw ``UUIDField`` column +raises ``FieldError: Expression contains mixed types``. Every endpoint below +returned a 500 until the UUID column was explicitly cast to text. +""" + +import pytest +from django.utils import timezone +from rest_framework import status + +from plane.db.models import ( + Cycle, + CycleIssue, + FileAsset, + Issue, + IssueAssignee, + Module, + ModuleIssue, + Project, + ProjectMember, + State, +) + + +@pytest.fixture +def user_with_avatar_asset(db, workspace, create_user): + """A user whose avatar comes from a FileAsset, i.e. the branch of the + ``Case`` expression that concatenates the asset UUID into a URL.""" + asset = FileAsset.objects.create( + workspace=workspace, + asset=f"{workspace.id}/avatar.png", + size=1024, + entity_type=FileAsset.EntityTypeContext.USER_AVATAR, + user=create_user, + is_uploaded=True, + ) + create_user.avatar_asset = asset + create_user.save() + return create_user + + +@pytest.fixture +def project(db, workspace, user_with_avatar_asset): + project = Project.objects.create( + name="Avatar URL Project", + identifier="AVU", + workspace=workspace, + created_by=user_with_avatar_asset, + module_view=True, + cycle_view=True, + ) + ProjectMember.objects.create( + workspace=workspace, + project=project, + member=user_with_avatar_asset, + role=20, + is_active=True, + ) + return project + + +@pytest.fixture +def assigned_issue(db, project, user_with_avatar_asset): + """A work item assigned to the avatar-bearing user, so the distribution + querysets have at least one row to annotate.""" + state = State.objects.create(name="Todo", group="unstarted", project=project, workspace=project.workspace) + issue = Issue.objects.create( + name="Avatar URL Issue", + project=project, + workspace=project.workspace, + state=state, + created_by=user_with_avatar_asset, + ) + IssueAssignee.objects.create( + issue=issue, + assignee=user_with_avatar_asset, + project=project, + workspace=project.workspace, + ) + return issue + + +@pytest.fixture +def module(db, project, assigned_issue): + module = Module.objects.create(name="Avatar URL Module", project=project, workspace=project.workspace) + ModuleIssue.objects.create(issue=assigned_issue, module=module, project=project, workspace=project.workspace) + return module + + +@pytest.fixture +def archived_module(db, project, assigned_issue): + module = Module.objects.create( + name="Archived Avatar URL Module", + project=project, + workspace=project.workspace, + archived_at=timezone.now(), + ) + ModuleIssue.objects.create(issue=assigned_issue, module=module, project=project, workspace=project.workspace) + return module + + +@pytest.fixture +def cycle(db, project, assigned_issue, user_with_avatar_asset): + cycle = Cycle.objects.create( + name="Avatar URL Cycle", + project=project, + workspace=project.workspace, + owned_by=user_with_avatar_asset, + start_date=timezone.now(), + end_date=timezone.now() + timezone.timedelta(days=7), + ) + CycleIssue.objects.create(issue=assigned_issue, cycle=cycle, project=project, workspace=project.workspace) + return cycle + + +@pytest.fixture +def archived_cycle(db, project, user_with_avatar_asset): + return Cycle.objects.create( + name="Archived Avatar URL Cycle", + project=project, + workspace=project.workspace, + owned_by=user_with_avatar_asset, + archived_at=timezone.now(), + ) + + +@pytest.mark.contract +class TestAvatarUrlAnnotation: + @pytest.mark.django_db + def test_module_retrieve_builds_avatar_url_from_asset( + self, session_client, workspace, project, module, user_with_avatar_asset + ): + response = session_client.get(f"/api/workspaces/{workspace.slug}/projects/{project.id}/modules/{module.id}/") + assert response.status_code == status.HTTP_200_OK + + assignees = list(response.data["distribution"]["assignees"]) + assert [a["avatar_url"] for a in assignees] == [ + f"/api/assets/v2/static/{user_with_avatar_asset.avatar_asset_id}/" + ] + + @pytest.mark.django_db + def test_archived_module_retrieve(self, session_client, workspace, project, archived_module): + response = session_client.get( + f"/api/workspaces/{workspace.slug}/projects/{project.id}/archived-modules/{archived_module.id}/" + ) + assert response.status_code == status.HTTP_200_OK + + @pytest.mark.django_db + def test_archived_cycle_retrieve(self, session_client, workspace, project, archived_cycle): + response = session_client.get( + f"/api/workspaces/{workspace.slug}/projects/{project.id}/archived-cycles/{archived_cycle.id}/" + ) + assert response.status_code == status.HTTP_200_OK + + @pytest.mark.django_db + def test_cycle_analytics(self, session_client, workspace, project, cycle): + response = session_client.get( + f"/api/workspaces/{workspace.slug}/projects/{project.id}/cycles/{cycle.id}/analytics/?type=issues" + ) + assert response.status_code == status.HTTP_200_OK + + @pytest.mark.django_db + def test_workspace_analytics(self, session_client, workspace, project, assigned_issue): + response = session_client.get( + f"/api/workspaces/{workspace.slug}/analytics/?x_axis=assignees__id&y_axis=issue_count" + ) + assert response.status_code == status.HTTP_200_OK + + @pytest.mark.django_db + def test_default_analytics(self, session_client, workspace, project, assigned_issue): + response = session_client.get(f"/api/workspaces/{workspace.slug}/default-analytics/") + assert response.status_code == status.HTTP_200_OK + + @pytest.mark.django_db + def test_project_advance_analytics_stats(self, session_client, workspace, project, assigned_issue): + response = session_client.get( + f"/api/workspaces/{workspace.slug}/projects/{project.id}/advance-analytics-stats/?type=work-items" + ) + assert response.status_code == status.HTTP_200_OK + + @pytest.mark.django_db + def test_entity_search_user_mention(self, session_client, workspace, project): + response = session_client.get( + f"/api/workspaces/{workspace.slug}/entity-search/" + f"?query_type=user_mention&query=Test&project_id={project.id}" + ) + assert response.status_code == status.HTTP_200_OK + + @pytest.mark.django_db + def test_cycle_transfer_issues(self, session_client, workspace, project, cycle, user_with_avatar_asset): + new_cycle = Cycle.objects.create( + name="Transfer Target Cycle", + project=project, + workspace=project.workspace, + owned_by=user_with_avatar_asset, + start_date=timezone.now() + timezone.timedelta(days=8), + end_date=timezone.now() + timezone.timedelta(days=14), + ) + response = session_client.post( + f"/api/workspaces/{workspace.slug}/projects/{project.id}/cycles/{cycle.id}/transfer-issues/", + {"new_cycle_id": str(new_cycle.id)}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK diff --git a/apps/api/plane/tests/unit/utils/test_issue_datetime_filters.py b/apps/api/plane/tests/unit/utils/test_issue_datetime_filters.py new file mode 100644 index 00000000000..9eed0bdfed7 --- /dev/null +++ b/apps/api/plane/tests/unit/utils/test_issue_datetime_filters.py @@ -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=`` matched only rows stamped exactly 00:00:00 -> always empty. +* ``created_at__range=,`` 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"} diff --git a/apps/api/plane/tests/unit/utils/test_issue_filters.py b/apps/api/plane/tests/unit/utils/test_issue_filters.py new file mode 100644 index 00000000000..ccb9298c529 --- /dev/null +++ b/apps/api/plane/tests/unit/utils/test_issue_filters.py @@ -0,0 +1,53 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +import pytest + +from plane.utils.issue_filters import filter_updated_at + + +@pytest.mark.unit +class TestFilterUpdatedAt: + """Regression test for the "Updated At" filter bug — the filter was + applying to the `created_at` column instead of `updated_at`, so work + items updated today but created earlier never showed up under + `Updated At -> is -> today`. See issue #9316.""" + + def test_get_method_targets_updated_at_column(self): + issue_filter = {} + params = {"updated_at": "2026-06-26"} + filter_updated_at(params, issue_filter, method="GET") + # The filter must key on updated_at, not created_at. A bare date goes through + # date_filter's single-value branch, which appends a lookup suffix + # (updated_at__date__contains), so match on the prefix rather than the exact key. + assert any(key.startswith("updated_at__date") for key in issue_filter) + assert not any("created_at" in key for key in issue_filter) + + def test_get_method_with_csv_targets_updated_at_column(self): + issue_filter = {} + params = {"updated_at": "2026-06-25,2026-06-26"} + filter_updated_at(params, issue_filter, method="GET") + assert all("updated_at" in key for key in issue_filter) + assert not any("created_at" in key for key in issue_filter) + + def test_post_method_targets_updated_at_column(self): + issue_filter = {} + params = {"updated_at": ["2026-06-26"]} + filter_updated_at(params, issue_filter, method="POST") + assert all("updated_at" in key for key in issue_filter) + assert not any("created_at" in key for key in issue_filter) + + def test_get_method_with_prefix_targets_updated_at_column(self): + issue_filter = {} + params = {"updated_at": "2026-06-26"} + filter_updated_at(params, issue_filter, method="GET", prefix="cycle_issue__") + keys = list(issue_filter) + assert any("cycle_issue__updated_at" in k for k in keys) + assert not any("created_at" in k for k in keys) + + def test_empty_get_filter_is_noop(self): + issue_filter = {} + params = {"updated_at": ""} + filter_updated_at(params, issue_filter, method="GET") + assert issue_filter == {} diff --git a/apps/api/plane/utils/cycle_transfer_issues.py b/apps/api/plane/utils/cycle_transfer_issues.py index 79634013822..3c012d84b7b 100644 --- a/apps/api/plane/utils/cycle_transfer_issues.py +++ b/apps/api/plane/utils/cycle_transfer_issues.py @@ -177,7 +177,7 @@ def transfer_cycle_issues( assignees__avatar_asset__isnull=False, then=Concat( Value("/api/assets/v2/static/"), - "assignees__avatar_asset", + Cast("assignees__avatar_asset", models.CharField()), Value("/"), ), ), @@ -299,7 +299,7 @@ def transfer_cycle_issues( assignees__avatar_asset__isnull=False, then=Concat( Value("/api/assets/v2/static/"), - "assignees__avatar_asset", + Cast("assignees__avatar_asset", models.CharField()), Value("/"), ), ), diff --git a/apps/api/plane/utils/filters/filterset.py b/apps/api/plane/utils/filters/filterset.py index 721bf4c7afd..f2a143b7faa 100644 --- a/apps/api/plane/utils/filters/filterset.py +++ b/apps/api/plane/utils/filters/filterset.py @@ -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 " 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 = { diff --git a/apps/api/plane/utils/issue_filters.py b/apps/api/plane/utils/issue_filters.py index ea31a529bb4..3669d8a1ad8 100644 --- a/apps/api/plane/utils/issue_filters.py +++ b/apps/api/plane/utils/issue_filters.py @@ -231,14 +231,14 @@ def filter_updated_at(params, issue_filter, method, prefix=""): if len(updated_ats) and "" not in updated_ats: date_filter( issue_filter=issue_filter, - date_term=f"{prefix}created_at__date", + date_term=f"{prefix}updated_at__date", queries=updated_ats, ) else: if params.get("updated_at", None) and len(params.get("updated_at")): date_filter( issue_filter=issue_filter, - date_term=f"{prefix}created_at__date", + date_term=f"{prefix}updated_at__date", queries=params.get("updated_at", []), ) return issue_filter