From e496b24f27ec83f3094b8e26219cd40d8040355f Mon Sep 17 00:00:00 2001 From: Manish Gupta <59428681+mguptahub@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:51:16 +0530 Subject: [PATCH 1/3] [WEB-8477] fix: created_at/updated_at filters return no work items (#9513) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../unit/utils/test_issue_datetime_filters.py | 105 ++++++++++++++++++ apps/api/plane/utils/filters/filterset.py | 31 ++++++ 2 files changed, 136 insertions(+) create mode 100644 apps/api/plane/tests/unit/utils/test_issue_datetime_filters.py 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/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 = { From 027a5a03300ce2cf0a72077701c7b47d88edd8e8 Mon Sep 17 00:00:00 2001 From: Satya Bharadwaj <103506237+Program2113@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:29:41 +0530 Subject: [PATCH 2/3] fix: cast avatar_asset to CharField to resolve mixed type errors in URL concatenation (#9512) --- apps/api/plane/app/views/analytic/base.py | 10 +- .../app/views/analytic/project_analytics.py | 4 +- apps/api/plane/app/views/cycle/archive.py | 4 +- apps/api/plane/app/views/cycle/base.py | 4 +- apps/api/plane/app/views/module/archive.py | 4 +- apps/api/plane/app/views/module/base.py | 4 +- apps/api/plane/app/views/search/base.py | 6 +- .../api/plane/bgtasks/analytic_plot_export.py | 4 +- apps/api/plane/space/utils/grouper.py | 6 +- apps/api/plane/space/views/issue.py | 6 +- .../app/test_avatar_url_annotation.py | 213 ++++++++++++++++++ apps/api/plane/utils/cycle_transfer_issues.py | 4 +- 12 files changed, 241 insertions(+), 28 deletions(-) create mode 100644 apps/api/plane/tests/contract/app/test_avatar_url_annotation.py 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/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("/"), ), ), From 39856932cd6b9bd17eab0920506d628190b47af2 Mon Sep 17 00:00:00 2001 From: Manish Gupta <59428681+mguptahub@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:30:21 +0530 Subject: [PATCH 3/3] [WEB-8477] fix(api): filter "Updated At" by updated_at column, not created_at (#9514) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit filter_updated_at() passed `created_at__date` as the date term in both its GET and POST branches, so filtering by "Updated At" actually filtered on the creation date. Work items updated today but created earlier never appeared under "Updated At -> is -> today", and the two filters returned identical result sets. Re-raise of community PR #9323 by @sanjibani, which is approved-ready but cannot merge because the CLA is unsigned. Original patch and tests carried over unchanged apart from the two fixes below; credit for the fix is theirs. Adjustments made while porting: - test_get_method_targets_updated_at_column asserted the exact key "updated_at__date", but date_filter's single-value branch appends a lookup suffix ("updated_at__date__contains"), so the assertion always failed. Match on the key prefix instead. - Added the missing trailing newline to the new test file. Verified on a local canary build: with one work item created 2020-01-01 but updated today, `?updated_at=2026-07-01;after` now returns 5 while `?created_at=2026-07-01;after` returns 4 — previously both returned 4. Unit tests pass (5); 4 of the 5 fail without the source change. Co-authored-by: Claude Opus 4.8 (1M context) --- .../tests/unit/utils/test_issue_filters.py | 53 +++++++++++++++++++ apps/api/plane/utils/issue_filters.py | 4 +- 2 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 apps/api/plane/tests/unit/utils/test_issue_filters.py 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/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