diff --git a/apiserver/plane/bgtasks/file_asset_task.py b/apiserver/plane/bgtasks/file_asset_task.py index e372355ef..91c17ecc8 100644 --- a/apiserver/plane/bgtasks/file_asset_task.py +++ b/apiserver/plane/bgtasks/file_asset_task.py @@ -26,3 +26,31 @@ def delete_file_asset(): file_asset.asset.delete(save=False) # Delete the file object file_asset.delete() + + +@shared_task +def file_asset_size(slug, email, members, issue_count, cycle_count, module_count): + asset_size = [] + # s3_client = boto3.client('s3') + assets_to_update = [] + + # for asset in FileAsset.objects.filter(size__isnull=True): + # try: + # key = f"{workspace_id}/{asset_key}" + # response = s3_client.head_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=key) + # size = response['ContentLength'] + # asset.size = size + # assets_to_update.append(asset) + # except Exception as e: + # # Handle exceptions such as S3 object not found + # print(f"Error updating asset size for {asset.asset.key}: {e}") + + # # Bulk update only objects that need updating + # FileAsset.objects.bulk_update(assets_to_update, ["size"], batch_size=50) + + for asset in FileAsset.objects.filter(size__isnull=True): + asset.size = asset.asset.size + asset_size.append(asset) + + FileAsset.objects.bulk_update(asset_size, ["size"], batch_size=50) + print("File asset size updated successfully") diff --git a/apiserver/plane/db/management/commands/file_asset_size.py b/apiserver/plane/db/management/commands/file_asset_size.py new file mode 100644 index 000000000..462a1b817 --- /dev/null +++ b/apiserver/plane/db/management/commands/file_asset_size.py @@ -0,0 +1,23 @@ +# Python imports +import getpass + +# Django imports +from django.core.management import BaseCommand + +# Module imports +from plane.db.models import User + + +class Command(BaseCommand): + help = "Check the file asset size of the file" + + + def handle(self, *args, **options): + + from plane.bgtasks.file_asset_task import file_asset_size + + file_asset_size.delay() + + self.stdout.write( + self.style.SUCCESS(f"File asset size pushed to queue") + ) diff --git a/apiserver/plane/db/migrations/0064_auto_20240321_0915.py b/apiserver/plane/db/migrations/0064_auto_20240321_0915.py index da809fa34..465c99611 100644 --- a/apiserver/plane/db/migrations/0064_auto_20240321_0915.py +++ b/apiserver/plane/db/migrations/0064_auto_20240321_0915.py @@ -33,7 +33,7 @@ def convert_issue_description_image_sources(apps, schema_editor): src = img.get("src", "") if src and (src.startswith(prefix1)): img["src"] = ( - f"/api/workspaces/{issue.workspace.slug}/projects/{issue.project_id}/issues/{issue.id}/attachments/{src[len(prefix1): ]}" + f"{src[len(prefix1): ]}" ) file_assets[src[len(prefix1) :]] = { "project_id": str(issue.project_id), @@ -45,7 +45,7 @@ def convert_issue_description_image_sources(apps, schema_editor): # prefix 2 if not settings.USE_MINIO and src and src.startswith(prefix2): img["src"] = ( - f"/api/workspaces/{issue.workspace.slug}/projects/{issue.project_id}/issues/{issue.id}/attachments/{src[len(prefix2): ]}" + f"{src[len(prefix2): ]}" ) file_assets[src[len(prefix2) :]] = { "project_id": str(issue.project_id), @@ -109,7 +109,7 @@ def convert_page_image_sources(apps, schema_editor): src = img.get("src", "") if src and (src.startswith(prefix1)): img["src"] = ( - f"/api/workspaces/{page.workspace.slug}/projects/{page.project_id}/issues/{page.id}/attachments/{src[len(prefix1): ]}/" + f"{src[len(prefix1): ]}/" ) file_assets[src[len(prefix1) :]] = { "project_id": str(page.project_id), @@ -121,7 +121,7 @@ def convert_page_image_sources(apps, schema_editor): # prefix 2 if not settings.USE_MINIO and src and src.startswith(prefix2): img["src"] = ( - f"/api/workspaces/{page.workspace.slug}/projects/{page.project_id}/issues/{page.id}/attachments/{src[len(prefix2): ]}/" + f"{src[len(prefix2): ]}/" ) file_assets[src[len(prefix2) :]] = { "project_id": str(page.project_id), @@ -181,7 +181,7 @@ def convert_comment_image_sources(apps, schema_editor): src = img.get("src", "") if src and (src.startswith(prefix1)): img["src"] = ( - f"/api/workspaces/{comment.workspace.slug}/projects/{comment.project_id}/issues/{comment.id}/attachments/{src[len(prefix1): ]}/" + f"{src[len(prefix1): ]}/" ) file_assets[src[len(prefix1) :]] = { "project_id": str(comment.project_id), @@ -193,7 +193,7 @@ def convert_comment_image_sources(apps, schema_editor): # prefix 2 if not settings.USE_MINIO and src and src.startswith(prefix2): img["src"] = ( - f"/api/workspaces/{comment.workspace.slug}/projects/{comment.project_id}/issues/{comment.id}/attachments/{src[len(prefix2): ]}/" + f"{src[len(prefix2): ]}/" ) file_assets[src[len(prefix2) :]] = { "project_id": str(comment.project_id), diff --git a/apiserver/plane/space/serializer/__init__.py b/apiserver/plane/space/serializer/__init__.py index cd10fb5c6..2a6d20ec6 100644 --- a/apiserver/plane/space/serializer/__init__.py +++ b/apiserver/plane/space/serializer/__init__.py @@ -3,3 +3,5 @@ from .user import UserLiteSerializer from .issue import LabelLiteSerializer, StateLiteSerializer from .state import StateSerializer, StateLiteSerializer + +from .asset import FileAssetSerializer \ No newline at end of file diff --git a/apiserver/plane/space/serializer/asset.py b/apiserver/plane/space/serializer/asset.py new file mode 100644 index 000000000..96f687c61 --- /dev/null +++ b/apiserver/plane/space/serializer/asset.py @@ -0,0 +1,15 @@ +from .base import BaseFileSerializer +from plane.db.models import FileAsset + + +class FileAssetSerializer(BaseFileSerializer): + + class Meta: + model = FileAsset + fields = "__all__" + read_only_fields = [ + "created_by", + "updated_by", + "created_at", + "updated_at", + ] diff --git a/apiserver/plane/space/serializer/base.py b/apiserver/plane/space/serializer/base.py index 4b92b06fc..5ffb2c974 100644 --- a/apiserver/plane/space/serializer/base.py +++ b/apiserver/plane/space/serializer/base.py @@ -56,3 +56,22 @@ class DynamicBaseSerializer(BaseSerializer): self.fields.pop(field_name) return self.fields + + +class BaseFileSerializer(DynamicBaseSerializer): + + class Meta: + abstract = True # Make this serializer abstract + + def to_representation(self, instance): + """ + Object instance -> Dict of primitive datatypes. + """ + response = super().to_representation(instance) + response[ + "asset" + ] = ( + instance.asset.name + ) # Ensure 'asset' field is consistently serialized + # Apply custom method to get download URL + return response \ No newline at end of file diff --git a/apiserver/plane/space/urls/issue.py b/apiserver/plane/space/urls/issue.py index 62f885a98..856770575 100644 --- a/apiserver/plane/space/urls/issue.py +++ b/apiserver/plane/space/urls/issue.py @@ -36,6 +36,26 @@ urlpatterns = [ ), name="issue-comments-project-board", ), + path( + "workspaces//projects//issues//attachments/", + IssueAttachmentPublicEndpoint.as_view(), + name="project-issue-attachments", + ), + path( + "workspaces//projects//issues//attachments///", + IssueAttachmentPublicEndpoint.as_view(), + name="project-issue-attachments", + ), + path( + "workspaces//projects//comments//attachments/", + CommentAssetPublicEndpoint.as_view(), + name="issue-comments-project-board-attachments", + ), + path( + "workspaces//projects//comments//attachments///", + CommentAssetPublicEndpoint.as_view(), + name="issue-comments-project-board-attachments", + ), path( "workspaces//project-boards//issues//reactions/", IssueReactionPublicViewSet.as_view( diff --git a/apiserver/plane/space/views/issue.py b/apiserver/plane/space/views/issue.py index 256c8ab40..f42a899fa 100644 --- a/apiserver/plane/space/views/issue.py +++ b/apiserver/plane/space/views/issue.py @@ -1,7 +1,6 @@ # Python imports import json -# Django imports from django.core.serializers.json import DjangoJSONEncoder from django.db.models import ( Case, @@ -18,10 +17,13 @@ from django.db.models import ( When, ) from django.http import HttpResponseRedirect + +# Django imports from django.utils import timezone # Third Party imports from rest_framework import status +from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response @@ -34,6 +36,7 @@ from plane.app.serializers import ( IssueReactionSerializer, IssueVoteSerializer, ) +from plane.app.views.base import BaseAPIView, BaseViewSet from plane.bgtasks.issue_activites_task import issue_activity from plane.db.models import ( CommentReaction, @@ -48,13 +51,12 @@ from plane.db.models import ( ProjectMember, ProjectPublicMember, State, + Workspace, ) from plane.utils.grouper import group_results from plane.utils.issue_filters import issue_filters from plane.utils.presigned_url_generator import generate_download_presigned_url -from .base import BaseAPIView, BaseViewSet - class IssueCommentPublicViewSet(BaseViewSet): serializer_class = IssueCommentSerializer @@ -218,6 +220,182 @@ class IssueCommentPublicViewSet(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) +class IssueAttachmentPublicEndpoint(BaseAPIView): + def get_permissions(self): + if self.action in ["get"]: + self.permission_classes = [ + AllowAny, + ] + else: + self.permission_classes = [ + IsAuthenticated, + ] + + return super(IssueAttachmentPublicEndpoint, self).get_permissions() + + parser_classes = ( + MultiPartParser, + FormParser, + JSONParser, + ) + + def post(self, request, slug, project_id, issue_id): + serializer = FileAssetSerializer(data=request.data) + workspace = Workspace.objects.get(slug=slug) + if serializer.is_valid(): + serializer.save( + workspace=workspace, + project_id=project_id, + entity_identifier=issue_id, + ) + issue_activity.delay( + type="attachment.activity.created", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + serializer.data, + cls=DjangoJSONEncoder, + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete( + self, request, slug, project_id, issue_id, workspace_id, asset_key + ): + key = f"{workspace_id}/{asset_key}" + asset = FileAsset.objects.get( + asset=key, + entity_identifier=issue_id, + entity_type="issue_attachment", + workspace__slug=slug, + project_id=project_id, + ) + asset.is_deleted = True + asset.save() + issue_activity.delay( + type="attachment.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + def get( + self, + request, + slug, + project_id, + issue_id, + workspace_id=None, + asset_key=None, + ): + if workspace_id and asset_key: + key = f"{workspace_id}/{asset_key}" + url = generate_download_presigned_url( + key=key, + host=request.get_host(), + scheme=request.scheme, + ) + return HttpResponseRedirect(url) + + # For listing + issue_attachments = FileAsset.objects.filter( + entity_type="issue_attachment", + entity_identifier=issue_id, + workspace__slug=slug, + project_id=project_id, + ) + serializer = FileAssetSerializer(issue_attachments, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class CommentAssetPublicEndpoint(BaseAPIView): + def get_permissions(self): + if self.action in ["get"]: + self.permission_classes = [ + AllowAny, + ] + else: + self.permission_classes = [ + IsAuthenticated, + ] + + return super(CommentAssetPublicEndpoint, self).get_permissions() + + parser_classes = ( + MultiPartParser, + FormParser, + JSONParser, + ) + + def post(self, request, slug, project_id, comment_id): + serializer = FileAssetSerializer(data=request.data) + workspace = Workspace.objects.get(slug=slug) + if serializer.is_valid(): + serializer.save( + workspace=workspace, + project_id=project_id, + entity_type="comment", + entity_identifier=comment_id, + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete( + self, request, slug, project_id, comment_id, workspace_id, asset_key + ): + key = f"{workspace_id}/{asset_key}" + asset = FileAsset.objects.get( + asset=key, + entity_identifier=comment_id, + entity_type="comment", + workspace__slug=slug, + project_id=project_id, + ) + asset.is_deleted = True + asset.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + def get( + self, + request, + slug, + project_id, + comment_id, + workspace_id=None, + asset_key=None, + ): + if workspace_id and asset_key: + key = f"{workspace_id}/{asset_key}" + url = generate_download_presigned_url( + key=key, + host=request.get_host(), + scheme=request.scheme, + ) + return HttpResponseRedirect(url) + + # For listing + comment_assets = FileAsset.objects.filter( + entity_type="comment", + entity_identifier=comment_id, + workspace__slug=slug, + project_id=project_id, + ) + serializer = FileAssetSerializer(comment_assets, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + class IssueReactionPublicViewSet(BaseViewSet): serializer_class = IssueReactionSerializer model = IssueReaction @@ -690,73 +868,3 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): }, status=status.HTTP_200_OK, ) - - -class IssueAttachmentPublicEndpoint(BaseAPIView): - - permission_classes = [ - AllowAny, - ] - - def get( - self, - request, - slug, - project_id, - issue_id, - workspace_id=None, - asset_key=None, - ): - if workspace_id and asset_key: - key = f"{workspace_id}/{asset_key}" - url = generate_download_presigned_url( - key=key, - host=request.get_host(), - scheme=request.scheme, - ) - return HttpResponseRedirect(url) - - # For listing - issue_attachments = FileAsset.objects.filter( - entity_type="issue_attachment", - entity_identifier=issue_id, - workspace__slug=slug, - project_id=project_id, - ) - serializer = FileAssetSerializer(issue_attachments, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - - -class CommentAssetPublicEndpoint(BaseAPIView): - - permission_classes = [ - AllowAny, - ] - - def get( - self, - request, - slug, - project_id, - comment_id, - workspace_id=None, - asset_key=None, - ): - if workspace_id and asset_key: - key = f"{workspace_id}/{asset_key}" - url = generate_download_presigned_url( - key=key, - host=request.get_host(), - scheme=request.scheme, - ) - return HttpResponseRedirect(url) - - # For listing - comment_assets = FileAsset.objects.filter( - entity_type="comment", - entity_identifier=comment_id, - workspace__slug=slug, - project_id=project_id, - ) - serializer = FileAssetSerializer(comment_assets, many=True) - return Response(serializer.data, status=status.HTTP_200_OK)