fix: cast avatar_asset to CharField to resolve mixed type errors in URL concatenation (#9512)

This commit is contained in:
Satya Bharadwaj
2026-07-30 20:29:41 +05:30
committed by GitHub
parent e496b24f27
commit 027a5a0330
12 changed files with 241 additions and 28 deletions

View File

@@ -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 @@ class AnalyticsEndpoint(BaseAPIView):
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 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
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 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
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 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
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("/"),
),
),

View File

@@ -22,7 +22,7 @@ from plane.db.models import (
)
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 @@ class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView):
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("/"),
),
),

View File

@@ -380,7 +380,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
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 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
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("/"),
),
),

View File

@@ -857,7 +857,7 @@ class CycleAnalyticsEndpoint(BaseAPIView):
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 @@ class CycleAnalyticsEndpoint(BaseAPIView):
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("/"),
),
),

View File

@@ -339,7 +339,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
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 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
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("/"),
),
),

View File

@@ -445,7 +445,7 @@ class ModuleViewSet(BaseViewSet):
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 @@ class ModuleViewSet(BaseViewSet):
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("/"),
),
),

View File

@@ -19,7 +19,7 @@ from django.db.models import (
)
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 @@ class SearchEndpoint(BaseAPIView):
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 @@ class SearchEndpoint(BaseAPIView):
member__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"member__avatar_asset",
Cast("member__avatar_asset", models.CharField()),
Value("/"),
),
),

View File

@@ -15,7 +15,7 @@ from django.core.mail import EmailMultiAlternatives, get_connection
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("/"),
),
),

View File

@@ -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("/"),
),
),

View File

@@ -26,7 +26,7 @@ from django.db.models import (
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 @@ class IssueRetrievePublicEndpoint(BaseAPIView):
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 @@ class IssueRetrievePublicEndpoint(BaseAPIView):
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("/"),
),
),

View File

@@ -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/"), <user>__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

View File

@@ -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("/"),
),
),