diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 57bff15c2..633ca6961 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -41,6 +41,7 @@ from .issue import ( IssueStateSerializer, IssueLinkSerializer, IssueLiteSerializer, + IssueAttachmentSerializer, ) from .module import ( diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index c5d53f838..34b8c16a5 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -25,6 +25,7 @@ from plane.db.models import ( Module, ModuleIssue, IssueLink, + IssueAttachment, ) @@ -439,6 +440,21 @@ class IssueLinkSerializer(BaseSerializer): return IssueLink.objects.create(**validated_data) +class IssueAttachmentSerializer(BaseSerializer): + class Meta: + model = IssueAttachment + fields = "__all__" + read_only_fields = [ + "created_by", + "updated_by", + "created_at", + "updated_at", + "workspace", + "project", + "issue", + ] + + # Issue Serializer with state details class IssueStateSerializer(BaseSerializer): state_detail = StateSerializer(read_only=True, source="state") @@ -466,6 +482,7 @@ class IssueSerializer(BaseSerializer): issue_cycle = IssueCycleDetailSerializer(read_only=True) issue_module = IssueModuleDetailSerializer(read_only=True) issue_link = IssueLinkSerializer(read_only=True, many=True) + issue_attachment = IssueAttachmentSerializer(read_only=True, many=True) sub_issues_count = serializers.IntegerField(read_only=True) class Meta: diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 800f584c8..44559ee0c 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -74,6 +74,7 @@ from plane.api.views import ( SubIssuesEndpoint, IssueLinkViewSet, BulkCreateIssueLabelsEndpoint, + IssueAttachmentEndpoint, ## End Issues # States StateViewSet, @@ -742,6 +743,16 @@ urlpatterns = [ ), name="project-issue-links", ), + path( + "workspaces//projects//issues//issue-attachments/", + IssueAttachmentEndpoint.as_view(), + name="project-issue-attachments", + ), + path( + "workspaces//projects//issues//issue-attachments//", + IssueAttachmentEndpoint.as_view(), + name="project-issue-attachments", + ), ## End Issues ## Issue Activity path( diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index f84e78a16..b945fecf7 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -69,6 +69,7 @@ from .issue import ( SubIssuesEndpoint, IssueLinkViewSet, BulkCreateIssueLabelsEndpoint, + IssueAttachmentEndpoint, ) from .auth_extended import ( diff --git a/apiserver/plane/api/views/asset.py b/apiserver/plane/api/views/asset.py index abdee4812..98c9f9caf 100644 --- a/apiserver/plane/api/views/asset.py +++ b/apiserver/plane/api/views/asset.py @@ -65,6 +65,8 @@ class FileAssetEndpoint(BaseAPIView): class UserAssetsEndpoint(BaseAPIView): + parser_classes = (MultiPartParser, FormParser) + def get(self, request, asset_key): try: files = FileAsset.objects.filter(asset=asset_key, created_by=request.user) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index d22c65092..dfe3b51cc 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -12,6 +12,7 @@ from django.views.decorators.gzip import gzip_page # Third Party imports from rest_framework.response import Response from rest_framework import status +from rest_framework.parsers import MultiPartParser, FormParser from sentry_sdk import capture_exception # Module imports @@ -28,6 +29,7 @@ from plane.api.serializers import ( IssueFlatSerializer, IssueLinkSerializer, IssueLiteSerializer, + IssueAttachmentSerializer, ) from plane.api.permissions import ( ProjectEntityPermission, @@ -43,6 +45,7 @@ from plane.db.models import ( IssueProperty, Label, IssueLink, + IssueAttachment, ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.grouper import group_results @@ -683,3 +686,51 @@ class BulkCreateIssueLabelsEndpoint(BaseAPIView): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class IssueAttachmentEndpoint(BaseAPIView): + serializer_class = IssueAttachmentSerializer + permission_classes = [ + ProjectEntityPermission, + ] + model = IssueAttachment + parser_classes = (MultiPartParser, FormParser) + + def post(self, request, slug, project_id, issue_id): + try: + serializer = IssueAttachmentSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(project_id=project_id, issue_id=issue_id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def delete(self, request, slug, project_id, issue_id, pk): + try: + issue_attachment = IssueAttachment.objects.get(pk=pk) + issue_attachment.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + except IssueAttachment.DoesNotExist: + return Response( + {"error": "Issue Attachment does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def get(self, request, slug, project_id, issue_id): + try: + issue_attachments = IssueAttachment.objects.filter( + issue_id=issue_id, workspace__slug=slug, project_id=project_id + ) + serilaizer = IssueAttachmentSerializer(issue_attachments, many=True) + return Response(serilaizer.data, status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 8a3021741..46b459bbd 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -32,6 +32,7 @@ from .issue import ( IssueBlocker, IssueLink, IssueSequence, + IssueAttachment, ) from .asset import FileAsset @@ -61,4 +62,4 @@ from .integration import ( from .importer import Importer -from .page import Page, PageBlock, PageFavorite, PageLabel \ No newline at end of file +from .page import Page, PageBlock, PageFavorite, PageLabel diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 655a03e75..aeee54348 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -1,3 +1,6 @@ +# Python import +from uuid import uuid4 + # Django imports from django.contrib.postgres.fields import ArrayField from django.db import models @@ -5,6 +8,7 @@ from django.conf import settings from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone +from django.core.exceptions import ValidationError # Module imports from . import ProjectBaseModel @@ -54,7 +58,6 @@ class Issue(ProjectBaseModel): through_fields=("issue", "assignee"), ) sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID") - attachments = ArrayField(models.URLField(), size=10, blank=True, default=list) labels = models.ManyToManyField( "db.Label", blank=True, related_name="labels", through="IssueLabel" ) @@ -194,6 +197,38 @@ class IssueLink(ProjectBaseModel): return f"{self.issue.name} {self.url}" +def get_upload_path(instance, filename): + return f"{instance.workspace.id}/{uuid4().hex}-{filename}" + + +def file_size(value): + limit = 5 * 1024 * 1024 + if value.size > limit: + raise ValidationError("File too large. Size should not exceed 5 MB.") + + +class IssueAttachment(ProjectBaseModel): + attributes = models.JSONField(default=dict) + asset = models.FileField( + upload_to=get_upload_path, + validators=[ + file_size, + ], + ) + issue = models.ForeignKey( + "db.Issue", on_delete=models.CASCADE, related_name="issue_attachment" + ) + + class Meta: + verbose_name = "Issue Attachment" + verbose_name_plural = "Issue Attachments" + db_table = "issue_attachments" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.asset}" + + class IssueActivity(ProjectBaseModel): issue = models.ForeignKey( Issue, on_delete=models.SET_NULL, null=True, related_name="issue_activity"