ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: filestorage upload и preview вложений

This commit is contained in:
DCCONSTRUCTIONS
2026-04-25 13:05:03 +03:00
parent 5f2d543cab
commit c4032e3040
43 changed files with 982 additions and 127 deletions
+4 -3
View File
@@ -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}"
+11 -9
View File
@@ -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)