mirror of
https://github.com/makeplane/plane.git
synced 2026-08-29 10:08:51 +02:00
fix: cast avatar_asset to CharField to resolve mixed type errors in URL concatenation (#9512)
This commit is contained in:
@@ -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("/"),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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("/"),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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("/"),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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("/"),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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("/"),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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("/"),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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("/"),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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("/"),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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("/"),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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("/"),
|
||||
),
|
||||
),
|
||||
|
||||
213
apps/api/plane/tests/contract/app/test_avatar_url_annotation.py
Normal file
213
apps/api/plane/tests/contract/app/test_avatar_url_annotation.py
Normal 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
|
||||
@@ -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("/"),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user