ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: filestorage upload и preview вложений
This commit is contained in:
@@ -43,6 +43,7 @@ from plane.utils.openapi import (
|
||||
asset_docs,
|
||||
)
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.upload_limits import resolve_workspace_upload_size_limit
|
||||
|
||||
|
||||
class UserAssetEndpoint(BaseAPIView):
|
||||
@@ -512,9 +513,6 @@ class GenericAssetEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if the file size is within the limit
|
||||
size_limit = min(size, settings.FILE_SIZE_LIMIT)
|
||||
|
||||
# Check if the file type is allowed
|
||||
if not type or type not in settings.ATTACHMENT_MIME_TYPES:
|
||||
return Response(
|
||||
@@ -525,6 +523,9 @@ class GenericAssetEndpoint(BaseAPIView):
|
||||
# Get the workspace
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
# Check if the file size is within the limit
|
||||
size_limit = resolve_workspace_upload_size_limit(workspace, size)
|
||||
|
||||
# asset key
|
||||
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
|
||||
|
||||
|
||||
@@ -86,6 +86,8 @@ from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from .base import BaseAPIView
|
||||
from plane.utils.host import base_host
|
||||
from plane.utils.issue_relation_mapper import get_actual_relation
|
||||
from plane.utils.attachment_preview import attachment_object_exists, get_attachment_preview_response
|
||||
from plane.utils.upload_limits import resolve_workspace_upload_size_limit
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
from plane.app.permissions import ROLE
|
||||
from plane.utils.openapi import (
|
||||
@@ -1874,8 +1876,6 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
size_limit = min(size, settings.FILE_SIZE_LIMIT)
|
||||
|
||||
if not type or type not in settings.ATTACHMENT_MIME_TYPES:
|
||||
return Response(
|
||||
{"error": "Invalid file type.", "status": False},
|
||||
@@ -1885,6 +1885,8 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView):
|
||||
# Get the workspace
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
size_limit = resolve_workspace_upload_size_limit(workspace, size)
|
||||
|
||||
# asset key
|
||||
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
|
||||
|
||||
@@ -2100,13 +2102,8 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
storage = S3Storage(request=request)
|
||||
presigned_url = storage.generate_presigned_url(
|
||||
object_name=asset.asset.name,
|
||||
disposition="attachment",
|
||||
filename=asset.attributes.get("name"),
|
||||
)
|
||||
return HttpResponseRedirect(presigned_url)
|
||||
disposition = "inline" if request.GET.get("preview") == "true" else "attachment"
|
||||
return get_attachment_preview_response(request, asset, disposition=disposition)
|
||||
|
||||
@issue_attachment_docs(
|
||||
operation_id="upload_work_item_attachment",
|
||||
@@ -2157,6 +2154,11 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
|
||||
|
||||
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
|
||||
serializer = IssueAttachmentSerializer(issue_attachment)
|
||||
if not attachment_object_exists(issue_attachment):
|
||||
return Response(
|
||||
{"error": "The uploaded attachment object was not found.", "status": False},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Send this activity only if the attachment is not uploaded before
|
||||
if not issue_attachment.is_uploaded:
|
||||
|
||||
@@ -192,6 +192,7 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||
issue_attachments = FileAsset.objects.filter(
|
||||
issue_id=issue_id,
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
is_uploaded=True,
|
||||
)
|
||||
# Serialize issue_attachments and add them to the response
|
||||
response["issue_attachments"] = IssueAttachmentLiteSerializer(issue_attachments, many=True).data
|
||||
|
||||
@@ -61,6 +61,11 @@ class WorkSpaceSerializer(DynamicBaseSerializer):
|
||||
)
|
||||
return value
|
||||
|
||||
def validate_storage_file_size_limit(self, value):
|
||||
if value is not None and int(value) < 1:
|
||||
raise serializers.ValidationError("Storage file size limit must be greater than zero")
|
||||
return value
|
||||
|
||||
class Meta:
|
||||
model = Workspace
|
||||
fields = "__all__"
|
||||
|
||||
@@ -24,6 +24,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.throttles.asset import AssetRateThrottle
|
||||
from plane.utils.upload_limits import resolve_workspace_upload_size_limit
|
||||
|
||||
|
||||
class UserAssetsV2Endpoint(BaseAPIView):
|
||||
@@ -342,12 +343,12 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get the size limit
|
||||
size_limit = min(settings.FILE_SIZE_LIMIT, size)
|
||||
|
||||
# Get the workspace
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
# Get the size limit
|
||||
size_limit = resolve_workspace_upload_size_limit(workspace, size)
|
||||
|
||||
# asset key
|
||||
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
|
||||
|
||||
@@ -541,12 +542,12 @@ class ProjectAssetEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get the size limit
|
||||
size_limit = min(settings.FILE_SIZE_LIMIT, size)
|
||||
|
||||
# Get the workspace
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
# Get the size limit
|
||||
size_limit = resolve_workspace_upload_size_limit(workspace, size)
|
||||
|
||||
# asset key
|
||||
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import uuid
|
||||
from django.utils import timezone
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponseRedirect
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
@@ -26,6 +25,8 @@ from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.settings.storage import S3Storage
|
||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from plane.utils.host import base_host
|
||||
from plane.utils.upload_limits import resolve_workspace_upload_size_limit
|
||||
from plane.utils.attachment_preview import attachment_object_exists, get_attachment_preview_response
|
||||
|
||||
|
||||
class IssueAttachmentEndpoint(BaseAPIView):
|
||||
@@ -86,7 +87,13 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id, issue_id):
|
||||
issue_attachments = FileAsset.objects.filter(issue_id=issue_id, workspace__slug=slug, project_id=project_id)
|
||||
issue_attachments = FileAsset.objects.filter(
|
||||
issue_id=issue_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
is_uploaded=True,
|
||||
)
|
||||
serializer = IssueAttachmentSerializer(issue_attachments, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -114,7 +121,7 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
|
||||
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
|
||||
|
||||
# Get the size limit
|
||||
size_limit = min(size, settings.FILE_SIZE_LIMIT)
|
||||
size_limit = resolve_workspace_upload_size_limit(workspace, size)
|
||||
|
||||
# Create a File Asset
|
||||
asset = FileAsset.objects.create(
|
||||
@@ -179,13 +186,8 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
storage = S3Storage(request=request)
|
||||
presigned_url = storage.generate_presigned_url(
|
||||
object_name=asset.asset.name,
|
||||
disposition="attachment",
|
||||
filename=asset.attributes.get("name"),
|
||||
)
|
||||
return HttpResponseRedirect(presigned_url)
|
||||
disposition = "inline" if request.GET.get("preview") == "true" else "attachment"
|
||||
return get_attachment_preview_response(request, asset, disposition=disposition)
|
||||
|
||||
# Get all the attachments
|
||||
issue_attachments = FileAsset.objects.filter(
|
||||
@@ -203,6 +205,11 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
|
||||
def patch(self, request, slug, project_id, issue_id, pk):
|
||||
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
|
||||
serializer = IssueAttachmentSerializer(issue_attachment)
|
||||
if not attachment_object_exists(issue_attachment):
|
||||
return Response(
|
||||
{"error": "The uploaded attachment object was not found.", "status": False},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Send this activity only if the attachment is not uploaded before
|
||||
if not issue_attachment.is_uploaded:
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Codex on 2026-04-25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("db", "0125_voice_task_sessions"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="workspace",
|
||||
name="storage_file_size_limit_enabled",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="workspace",
|
||||
name="storage_file_size_limit",
|
||||
field=models.PositiveBigIntegerField(default=5242880),
|
||||
),
|
||||
]
|
||||
@@ -137,6 +137,8 @@ class Workspace(BaseModel):
|
||||
organization_size = models.CharField(max_length=20, blank=True, null=True)
|
||||
timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES)
|
||||
background_color = models.CharField(max_length=255, default=get_random_color)
|
||||
storage_file_size_limit_enabled = models.BooleanField(default=True)
|
||||
storage_file_size_limit = models.PositiveBigIntegerField(default=5242880)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the Workspace"""
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from plane.utils.upload_limits import get_workspace_file_size_limit, resolve_workspace_upload_size_limit
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestWorkspaceUploadLimits:
|
||||
def test_returns_none_when_workspace_limit_is_disabled(self):
|
||||
workspace = SimpleNamespace(storage_file_size_limit_enabled=False, storage_file_size_limit=1024)
|
||||
|
||||
assert get_workspace_file_size_limit(workspace) is None
|
||||
|
||||
def test_caps_requested_size_by_workspace_limit(self):
|
||||
workspace = SimpleNamespace(storage_file_size_limit_enabled=True, storage_file_size_limit=1024)
|
||||
|
||||
assert resolve_workspace_upload_size_limit(workspace, 4096) == 1024
|
||||
|
||||
def test_uses_requested_size_when_workspace_limit_is_disabled(self):
|
||||
workspace = SimpleNamespace(storage_file_size_limit_enabled=False, storage_file_size_limit=1024)
|
||||
|
||||
assert resolve_workspace_upload_size_limit(workspace, 4096) == 4096
|
||||
@@ -0,0 +1,59 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
from botocore.exceptions import ClientError
|
||||
from django.http import HttpResponse, StreamingHttpResponse
|
||||
|
||||
from plane.settings.storage import S3Storage
|
||||
|
||||
|
||||
def attachment_object_exists(asset):
|
||||
storage = S3Storage(request=None)
|
||||
try:
|
||||
storage.s3_client.head_object(
|
||||
Bucket=storage.aws_storage_bucket_name,
|
||||
Key=str(asset.asset.name),
|
||||
)
|
||||
return True
|
||||
except ClientError:
|
||||
return False
|
||||
|
||||
|
||||
def get_attachment_preview_response(request, asset, disposition="inline"):
|
||||
storage = S3Storage(request=None)
|
||||
range_header = request.META.get("HTTP_RANGE")
|
||||
request_kwargs = {
|
||||
"Bucket": storage.aws_storage_bucket_name,
|
||||
"Key": str(asset.asset.name),
|
||||
}
|
||||
if range_header:
|
||||
request_kwargs["Range"] = range_header
|
||||
|
||||
try:
|
||||
storage_response = storage.s3_client.get_object(**request_kwargs)
|
||||
except ClientError:
|
||||
return HttpResponse("Attachment object not found.", status=404)
|
||||
|
||||
content_type = (
|
||||
asset.attributes.get("type")
|
||||
or storage_response.get("ContentType")
|
||||
or "application/octet-stream"
|
||||
)
|
||||
filename = quote(asset.attributes.get("name") or "attachment")
|
||||
response = StreamingHttpResponse(
|
||||
storage_response["Body"].iter_chunks(chunk_size=8192),
|
||||
status=206 if storage_response.get("ContentRange") else 200,
|
||||
content_type=content_type,
|
||||
)
|
||||
response["Content-Disposition"] = f"{disposition}; filename*=UTF-8''{filename}"
|
||||
response["Accept-Ranges"] = "bytes"
|
||||
|
||||
if storage_response.get("ContentLength") is not None:
|
||||
response["Content-Length"] = storage_response["ContentLength"]
|
||||
if storage_response.get("ContentRange"):
|
||||
response["Content-Range"] = storage_response["ContentRange"]
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,24 @@
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def get_workspace_file_size_limit(workspace):
|
||||
if not getattr(workspace, "storage_file_size_limit_enabled", True):
|
||||
return None
|
||||
|
||||
limit = getattr(workspace, "storage_file_size_limit", None) or settings.FILE_SIZE_LIMIT
|
||||
return max(1, int(limit))
|
||||
|
||||
|
||||
def resolve_workspace_upload_size_limit(workspace, requested_size):
|
||||
try:
|
||||
requested_size = int(requested_size)
|
||||
except (TypeError, ValueError):
|
||||
requested_size = settings.FILE_SIZE_LIMIT
|
||||
|
||||
requested_size = max(1, requested_size)
|
||||
workspace_limit = get_workspace_file_size_limit(workspace)
|
||||
|
||||
if workspace_limit is None:
|
||||
return requested_size
|
||||
|
||||
return min(requested_size, workspace_limit)
|
||||
Reference in New Issue
Block a user