From b01f9ac63f3cfb400a7d22cc873eaf003747a8b2 Mon Sep 17 00:00:00 2001 From: Program2113 Date: Thu, 30 Jul 2026 18:20:52 +0530 Subject: [PATCH] fix: cast avatar_asset to CharField to resolve mixed type errors in URL concatenation --- 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("/"), ), ),