Skip to content

[WIKI-419] chore: new asset duplicate endpoint added #7172

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: preview
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apiserver/plane/app/throttles/asset.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from rest_framework.throttling import SimpleRateThrottle


class AssetRateThrottle(SimpleRateThrottle):
scope = "asset_id"

def get_cache_key(self, request, view):
asset_id = view.kwargs.get("asset_id")
if not asset_id:
return None
return f"throttle_asset_{asset_id}"
6 changes: 6 additions & 0 deletions apiserver/plane/app/urls/asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
ProjectAssetEndpoint,
ProjectBulkAssetEndpoint,
AssetCheckEndpoint,
DuplicateAssetEndpoint,
WorkspaceAssetDownloadEndpoint,
ProjectAssetDownloadEndpoint,
)
Expand Down Expand Up @@ -91,6 +92,11 @@
AssetCheckEndpoint.as_view(),
name="asset-check",
),
path(
"assets/v2/workspaces/<str:slug>/duplicate-assets/<uuid:asset_id>/",
DuplicateAssetEndpoint.as_view(),
name="duplicate-assets",
),
path(
"assets/v2/workspaces/<str:slug>/download/<uuid:asset_id>/",
WorkspaceAssetDownloadEndpoint.as_view(),
Expand Down
1 change: 1 addition & 0 deletions apiserver/plane/app/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
ProjectAssetEndpoint,
ProjectBulkAssetEndpoint,
AssetCheckEndpoint,
DuplicateAssetEndpoint,
WorkspaceAssetDownloadEndpoint,
ProjectAssetDownloadEndpoint,
)
Expand Down
80 changes: 79 additions & 1 deletion apiserver/plane/app/views/asset/v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from plane.app.permissions import allow_permission, ROLE
from plane.utils.cache import invalidate_cache_directly
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata

from plane.app.throttles.asset import AssetRateThrottle

class UserAssetsV2Endpoint(BaseAPIView):
"""This endpoint is used to upload user profile images."""
Expand Down Expand Up @@ -720,6 +720,84 @@ def get(self, request, slug, asset_id):
return Response({"exists": asset}, status=status.HTTP_200_OK)


class DuplicateAssetEndpoint(BaseAPIView):

throttle_classes = [AssetRateThrottle]

def get_entity_id_field(self, entity_type, entity_id):
# Workspace Logo
if entity_type == FileAsset.EntityTypeContext.WORKSPACE_LOGO:
return {"workspace_id": entity_id}

# Project Cover
if entity_type == FileAsset.EntityTypeContext.PROJECT_COVER:
return {"project_id": entity_id}

# User Avatar and Cover
if entity_type in [
FileAsset.EntityTypeContext.USER_AVATAR,
FileAsset.EntityTypeContext.USER_COVER,
]:
return {"user_id": entity_id}

# Issue Attachment and Description
if entity_type in [
FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
FileAsset.EntityTypeContext.ISSUE_DESCRIPTION,
]:
return {"issue_id": entity_id}

# Page Description
if entity_type == FileAsset.EntityTypeContext.PAGE_DESCRIPTION:
return {"page_id": entity_id}

# Comment Description
if entity_type == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION:
return {"comment_id": entity_id}

return {}

@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def post(self, request, slug, asset_id):
project_id = request.data.get("project_id", None)
entity_id = request.data.get("entity_id", None)
entity_type = request.data.get("entity_type", None)

Comment on lines +760 to +765
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Validate request payload before touching the DB

entity_type, entity_id and (optionally) project_id are accepted unchecked.
If entity_type is missing or invalid, FileAsset will be created with an invalid enum value and raise at save-time.
Add upfront validation consistent with other endpoints.

+        if entity_type not in FileAsset.EntityTypeContext.values:
+            return Response(
+                {"error": "Invalid entity type", "status": False},
+                status=status.HTTP_400_BAD_REQUEST,
+            )
+        if not entity_id:
+            return Response(
+                {"error": "Missing entity_id", "status": False},
+                status=status.HTTP_400_BAD_REQUEST,
+            )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def post(self, request, slug, asset_id):
project_id = request.data.get("project_id", None)
entity_id = request.data.get("entity_id", None)
entity_type = request.data.get("entity_type", None)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def post(self, request, slug, asset_id):
project_id = request.data.get("project_id", None)
entity_id = request.data.get("entity_id", None)
entity_type = request.data.get("entity_type", None)
if entity_type not in FileAsset.EntityTypeContext.values:
return Response(
{"error": "Invalid entity type", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
if not entity_id:
return Response(
{"error": "Missing entity_id", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
# …rest of the existing implementation…
🤖 Prompt for AI Agents
In apiserver/plane/app/views/asset/v2.py around lines 760 to 765, the request
payload fields entity_type, entity_id, and optionally project_id are used
without validation, which can cause errors when saving FileAsset with invalid
enum values. Add validation logic before any database operations to check that
entity_type is present and valid, entity_id is provided, and project_id if given
is valid. This validation should follow the pattern used in other endpoints to
ensure consistent and early error handling.

workspace = Workspace.objects.get(slug=slug)
if project_id:
# check if project exists in the workspace
if not Project.objects.filter(id=project_id, workspace=workspace).exists():
return Response(
{"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND
)

storage = S3Storage()
original_asset = FileAsset.objects.filter(
workspace=workspace, id=asset_id
).first()
# for original_asset in original_assets:
destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}"
Comment on lines +766 to +779
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Guard against missing or not-uploaded source asset

original_asset = … .first() may return None, or the asset may still be uploading.
Accessing .attributes on None (or copying an incomplete asset) will raise 500s.

-        original_asset = FileAsset.objects.filter(
-            workspace=workspace, id=asset_id
-        ).first()
+        original_asset = FileAsset.objects.filter(
+            workspace=workspace,
+            id=asset_id,
+            is_uploaded=True,
+        ).first()
+
+        if not original_asset:
+            return Response(
+                {"error": "Source asset not found or not uploaded"},
+                status=status.HTTP_404_NOT_FOUND,
+            )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
workspace = Workspace.objects.get(slug=slug)
if project_id:
# check if project exists in the workspace
if not Project.objects.filter(id=project_id, workspace=workspace).exists():
return Response(
{"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND
)
storage = S3Storage()
original_asset = FileAsset.objects.filter(
workspace=workspace, id=asset_id
).first()
# for original_asset in original_assets:
destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}"
workspace = Workspace.objects.get(slug=slug)
if project_id:
# check if project exists in the workspace
if not Project.objects.filter(id=project_id, workspace=workspace).exists():
return Response(
{"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND
)
storage = S3Storage()
- original_asset = FileAsset.objects.filter(
- workspace=workspace, id=asset_id
- ).first()
+ original_asset = FileAsset.objects.filter(
+ workspace=workspace,
+ id=asset_id,
+ is_uploaded=True,
+ ).first()
+
+ if not original_asset:
+ return Response(
+ {"error": "Source asset not found or not uploaded"},
+ status=status.HTTP_404_NOT_FOUND,
+ )
# for original_asset in original_assets:
destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}"
🧰 Tools
🪛 Ruff (0.11.9)

779-779: Line too long (102 > 88)

(E501)

🤖 Prompt for AI Agents
In apiserver/plane/app/views/asset/v2.py around lines 766 to 779, the code
assigns original_asset using .first() which can return None if no matching asset
is found. Accessing original_asset.attributes without checking for None can
cause a server error. Add a guard to check if original_asset is None or if it is
still uploading before proceeding. If either condition is true, return an
appropriate error response to prevent 500 errors.

duplicated_asset = FileAsset.objects.create(
attributes={
"name": original_asset.attributes.get("name"),
"type": original_asset.attributes.get("type"),
"size": original_asset.attributes.get("size"),
},
asset=destination_key,
size=original_asset.size,
workspace=workspace,
created_by_id=request.user.id,
entity_type=entity_type,
project_id=project_id if project_id else None,
storage_metadata=original_asset.storage_metadata,
**self.get_entity_id_field(entity_type=entity_type, entity_id=entity_id),
)
storage.copy_object(original_asset.asset, destination_key)
# Update the is_uploaded field for all newly created assets
FileAsset.objects.filter(id=duplicated_asset.id).update(is_uploaded=True)

return Response(duplicated_asset.id, status=status.HTTP_200_OK)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

API should return JSON, not bare UUID

The rest of the API surface consistently wraps IDs in a JSON object. Returning a raw UUID string breaks client expectations and makes the response non-extensible.

-        return Response(duplicated_asset.id, status=status.HTTP_200_OK)
+        return Response(
+            {"asset_id": str(duplicated_asset.id)},
+            status=status.HTTP_201_CREATED,
+        )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return Response(duplicated_asset.id, status=status.HTTP_200_OK)
return Response(
{"asset_id": str(duplicated_asset.id)},
status=status.HTTP_201_CREATED,
)
🤖 Prompt for AI Agents
In apiserver/plane/app/views/asset/v2.py at line 799, the API currently returns
a bare UUID string which breaks client expectations and consistency. Modify the
return statement to wrap the duplicated_asset.id inside a JSON object, for
example returning a dictionary with a key like "id" mapped to the UUID, and then
pass that dictionary to the Response. This ensures the response is JSON
formatted and extensible.


class WorkspaceAssetDownloadEndpoint(BaseAPIView):
"""Endpoint to generate a download link for an asset with content-disposition=attachment."""

Expand Down
5 changes: 5 additions & 0 deletions apiserver/plane/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework.authentication.SessionAuthentication",
),
"DEFAULT_THROTTLE_CLASSES": ("rest_framework.throttling.AnonRateThrottle",),
"DEFAULT_THROTTLE_RATES": {
"anon": "30/minute",
"asset_id": "5/minute",
},
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
"DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",),
Expand Down
19 changes: 18 additions & 1 deletion web/core/services/file.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AxiosRequestConfig } from "axios";
// plane types
// plane imports
import { TFileEntityInfo, TFileSignedURLResponse } from "@plane/types";
import { EFileAssetType } from "@plane/types/src/enums";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
import { generateFileUploadPayload, getAssetIdFromUrl, getFileMetaDataForUpload } from "@/helpers/file.helper";
Expand Down Expand Up @@ -258,6 +259,22 @@ export class FileService extends APIService {
});
}

async duplicateAssets(
workspaceSlug: string,
data: {
entity_id: string;
entity_type: EFileAssetType;
project_id?: string;
asset_ids: string[];
}
): Promise<Record<string, string>> {
return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/duplicate-assets/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}

cancelUpload() {
this.cancelSource.cancel("Upload canceled");
}
Expand Down