From 6719738a9fddc33ebed81cde6bea07579245d86a Mon Sep 17 00:00:00 2001 From: NarayanBavisetti Date: Tue, 13 Feb 2024 15:23:24 +0530 Subject: [PATCH] chore: issue attachment and description endpoint for space --- apiserver/plane/bgtasks/file_asset_task.py | 28 +++ .../db/management/commands/file_asset_size.py | 23 +++ .../db/migrations/0060_fileasset_size.py | 12 +- apiserver/plane/space/serializer/__init__.py | 2 + apiserver/plane/space/serializer/asset.py | 15 ++ apiserver/plane/space/serializer/base.py | 19 ++ apiserver/plane/space/urls/issue.py | 22 +++ apiserver/plane/space/views/__init__.py | 2 + apiserver/plane/space/views/issue.py | 182 ++++++++++++++++++ 9 files changed, 299 insertions(+), 6 deletions(-) create mode 100644 apiserver/plane/db/management/commands/file_asset_size.py create mode 100644 apiserver/plane/space/serializer/asset.py 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/0060_fileasset_size.py b/apiserver/plane/db/migrations/0060_fileasset_size.py index 054d69bdf..6ef2212b3 100644 --- a/apiserver/plane/db/migrations/0060_fileasset_size.py +++ b/apiserver/plane/db/migrations/0060_fileasset_size.py @@ -35,7 +35,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), @@ -47,7 +47,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), @@ -110,7 +110,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), @@ -122,7 +122,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 099eace5d..355b367f9 100644 --- a/apiserver/plane/space/urls/issue.py +++ b/apiserver/plane/space/urls/issue.py @@ -6,6 +6,8 @@ from plane.space.views import ( IssueCommentPublicViewSet, IssueReactionPublicViewSet, CommentReactionPublicViewSet, + CommentAssetPublicEndpoint, + IssueAttachmentPublicEndpoint ) urlpatterns = [ @@ -35,6 +37,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/__init__.py b/apiserver/plane/space/views/__init__.py index 5130e04d5..d2935c240 100644 --- a/apiserver/plane/space/views/__init__.py +++ b/apiserver/plane/space/views/__init__.py @@ -10,6 +10,8 @@ from .issue import ( IssueVotePublicViewSet, IssueRetrievePublicEndpoint, ProjectIssuesPublicEndpoint, + CommentAssetPublicEndpoint, + IssueAttachmentPublicEndpoint, ) from .inbox import InboxIssuePublicViewSet diff --git a/apiserver/plane/space/views/issue.py b/apiserver/plane/space/views/issue.py index 7f8499474..7714bca1c 100644 --- a/apiserver/plane/space/views/issue.py +++ b/apiserver/plane/space/views/issue.py @@ -3,6 +3,7 @@ import json # Django imports from django.utils import timezone +from django.http import HttpResponseRedirect from django.db.models import ( Prefetch, OuterRef, @@ -24,6 +25,8 @@ from django.core.serializers.json import DjangoJSONEncoder from rest_framework.response import Response from rest_framework import status from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.parsers import MultiPartParser, FormParser, JSONParser + # Module imports from .base import BaseViewSet, BaseAPIView @@ -33,6 +36,7 @@ from plane.app.serializers import ( CommentReactionSerializer, IssueVoteSerializer, IssuePublicSerializer, + FileAssetSerializer, ) from plane.db.models import ( @@ -48,10 +52,12 @@ from plane.db.models import ( ProjectDeployBoard, IssueVote, ProjectPublicMember, + Workspace, ) from plane.bgtasks.issue_activites_task import issue_activity 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 class IssueCommentPublicViewSet(BaseViewSet): @@ -216,6 +222,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