Compare commits

...

14 Commits

Author SHA1 Message Date
Palanikannan1437
ab5e698065 feat: init working version of image blobs rendering 2024-02-09 21:23:53 +05:30
Nikhil
e09d7f9533 Merge branch 'develop' into chore/file-uploads 2024-02-08 16:23:34 +05:30
pablohashescobar
16c78100c0 dev: docker variables upgrade and nginx template update 2024-02-08 13:22:20 +05:30
pablohashescobar
2688e41cef dev: update the default create bucket script to create private bucket 2024-02-07 20:33:37 +05:30
pablohashescobar
6776c2d2d1 dev: add nginx headers for request host 2024-02-07 20:20:54 +05:30
pablohashescobar
e19eb3f074 dev: update the issue attachment default type 2024-02-05 16:00:13 +05:30
pablohashescobar
f95f24231f dev: comment assets 2024-02-05 15:12:56 +05:30
pablohashescobar
02e5e0da4b dev: update attachments for issues 2024-02-05 14:30:49 +05:30
pablohashescobar
cedc08bc08 dev: sync data for issue attachments 2024-02-05 13:10:24 +05:30
pablohashescobar
31cca8d07e dev: back migration for assets in issue, comments and page 2024-02-05 12:04:20 +05:30
pablohashescobar
6c97bcefbf dev: back migration for urls 2024-02-04 11:27:50 +05:30
pablohashescobar
e1f0da5e6c dev: update file image urls to backend apis 2024-02-02 14:48:50 +05:30
pablohashescobar
94f445cc08 dev: update user assets with backend streaming 2024-02-01 15:55:43 +05:30
pablohashescobar
42f307421a dev: update the response for assets 2024-01-31 18:43:02 +05:30
58 changed files with 1783 additions and 333 deletions

View File

@@ -5,9 +5,7 @@ from .issue import (
IssueSerializer,
LabelSerializer,
IssueLinkSerializer,
IssueAttachmentSerializer,
IssueCommentSerializer,
IssueAttachmentSerializer,
IssueActivitySerializer,
IssueExpandSerializer,
)

View File

@@ -17,7 +17,6 @@ from plane.db.models import (
IssueLabel,
IssueLink,
IssueComment,
IssueAttachment,
IssueActivity,
ProjectMember,
)
@@ -296,22 +295,6 @@ class IssueLinkSerializer(BaseSerializer):
return IssueLink.objects.create(**validated_data)
class IssueAttachmentSerializer(BaseSerializer):
class Meta:
model = IssueAttachment
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"issue",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
class IssueCommentSerializer(BaseSerializer):
is_member = serializers.BooleanField(read_only=True)

View File

@@ -17,7 +17,7 @@ from plane.db.models import (
Issue,
CycleIssue,
IssueLink,
IssueAttachment,
FileAsset,
)
from plane.app.permissions import ProjectEntityPermission
from plane.api.serializers import (
@@ -430,8 +430,9 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))

View File

@@ -1,6 +1,5 @@
# Python imports
import json
from itertools import chain
# Django imports
from django.db import IntegrityError
@@ -32,13 +31,13 @@ from plane.app.permissions import (
)
from plane.db.models import (
Issue,
IssueAttachment,
IssueLink,
Project,
Label,
ProjectMember,
IssueComment,
IssueActivity,
FileAsset,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.api.serializers import (
@@ -127,8 +126,9 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))

View File

@@ -19,8 +19,8 @@ from plane.db.models import (
ModuleLink,
Issue,
ModuleIssue,
IssueAttachment,
IssueLink,
FileAsset,
)
from plane.api.serializers import (
ModuleSerializer,
@@ -313,8 +313,9 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))

View File

@@ -60,7 +60,6 @@ from .issue import (
IssueStateSerializer,
IssueLinkSerializer,
IssueLiteSerializer,
IssueAttachmentSerializer,
IssueSubscriberSerializer,
IssueReactionSerializer,
CommentReactionSerializer,

View File

@@ -1,8 +1,9 @@
from .base import BaseSerializer
from .base import BaseFileSerializer
from plane.db.models import FileAsset
class FileAssetSerializer(BaseSerializer):
class FileAssetSerializer(BaseFileSerializer):
class Meta:
model = FileAsset
fields = "__all__"

View File

@@ -1,4 +1,5 @@
from rest_framework import serializers
from plane.settings.storage import S3PrivateBucketStorage
class BaseSerializer(serializers.ModelSerializer):
@@ -60,7 +61,7 @@ class DynamicBaseSerializer(BaseSerializer):
CycleIssueSerializer,
IssueFlatSerializer,
IssueRelationSerializer,
InboxIssueLiteSerializer
InboxIssueLiteSerializer,
)
# Expansion mapper
@@ -81,10 +82,22 @@ class DynamicBaseSerializer(BaseSerializer):
"issue_cycle": CycleIssueSerializer,
"parent": IssueSerializer,
"issue_relation": IssueRelationSerializer,
"issue_inbox" : InboxIssueLiteSerializer,
"issue_inbox": InboxIssueLiteSerializer,
}
self.fields[field] = expansion[field](many=True if field in ["members", "assignees", "labels", "issue_cycle", "issue_relation", "issue_inbox"] else False)
self.fields[field] = expansion[field](
many=True
if field
in [
"members",
"assignees",
"labels",
"issue_cycle",
"issue_relation",
"issue_inbox",
]
else False
)
return self.fields
@@ -105,7 +118,7 @@ class DynamicBaseSerializer(BaseSerializer):
LabelSerializer,
CycleIssueSerializer,
IssueRelationSerializer,
InboxIssueLiteSerializer
InboxIssueLiteSerializer,
)
# Expansion mapper
@@ -126,7 +139,7 @@ class DynamicBaseSerializer(BaseSerializer):
"issue_cycle": CycleIssueSerializer,
"parent": IssueSerializer,
"issue_relation": IssueRelationSerializer,
"issue_inbox" : InboxIssueLiteSerializer,
"issue_inbox": InboxIssueLiteSerializer,
}
# Check if field in expansion then expand the field
if expand in expansion:
@@ -146,3 +159,22 @@ class DynamicBaseSerializer(BaseSerializer):
)
return response
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

View File

@@ -5,7 +5,7 @@ from django.utils import timezone
from rest_framework import serializers
# Module imports
from .base import BaseSerializer, DynamicBaseSerializer
from .base import BaseSerializer, DynamicBaseSerializer, BaseFileSerializer
from .user import UserLiteSerializer
from .state import StateSerializer, StateLiteSerializer
from .project import ProjectLiteSerializer
@@ -25,7 +25,7 @@ from plane.db.models import (
Module,
ModuleIssue,
IssueLink,
IssueAttachment,
FileAsset,
IssueReaction,
CommentReaction,
IssueVote,
@@ -444,21 +444,6 @@ 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",
]
class IssueReactionSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
@@ -503,9 +488,7 @@ class IssueCommentSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(
read_only=True, source="workspace"
)
comment_reactions = CommentReactionSerializer(
read_only=True, many=True
)
comment_reactions = CommentReactionSerializer(read_only=True, many=True)
is_member = serializers.BooleanField(read_only=True)
class Meta:
@@ -615,7 +598,10 @@ class IssueSerializer(DynamicBaseSerializer):
def get_module_ids(self, obj):
# Access the prefetched modules and extract module IDs
return [module for module in obj.issue_module.values_list("module_id", flat=True)]
return [
module
for module in obj.issue_module.values_list("module_id", flat=True)
]
class IssueLiteSerializer(DynamicBaseSerializer):

View File

@@ -4,7 +4,6 @@ from rest_framework import serializers
# Module import
from .base import BaseSerializer
from plane.db.models import User, Workspace, WorkspaceMemberInvite
from plane.license.models import InstanceAdmin, Instance
class UserSerializer(BaseSerializer):

View File

@@ -3,7 +3,6 @@ from django.urls import path
from plane.app.views import (
FileAssetEndpoint,
UserAssetsEndpoint,
FileAssetViewSet,
)
@@ -19,16 +18,6 @@ urlpatterns = [
FileAssetEndpoint.as_view(),
name="file-assets",
),
path(
"users/file-assets/",
UserAssetsEndpoint.as_view(),
name="user-file-assets",
),
path(
"users/file-assets/<str:asset_key>/",
UserAssetsEndpoint.as_view(),
name="user-file-assets",
),
path(
"workspaces/file-assets/<uuid:workspace_id>/<str:asset_key>/restore/",
FileAssetViewSet.as_view(

View File

@@ -21,6 +21,7 @@ from plane.app.views import (
IssueArchiveViewSet,
IssueRelationViewSet,
IssueDraftViewSet,
CommentAssetEndpoint,
)
@@ -117,12 +118,12 @@ urlpatterns = [
name="project-issue-links",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/",
IssueAttachmentEndpoint.as_view(),
name="project-issue-attachments",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/<uuid:workspace_id>/<str:asset_key>/",
IssueAttachmentEndpoint.as_view(),
name="project-issue-attachments",
),
@@ -313,4 +314,16 @@ urlpatterns = [
),
name="project-issue-draft",
),
# Comment Assets
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/comments/<uuid:comment_id>/attachments/",
CommentAssetEndpoint.as_view(),
name="project-comment-attachments",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/comments/<uuid:comment_id>/attachments/<uuid:workspace_id>/<str:asset_key>/",
CommentAssetEndpoint.as_view(),
name="project-comment-attachments",
),
## End Comments
]

View File

@@ -6,6 +6,7 @@ from plane.app.views import (
PageFavoriteViewSet,
PageLogEndpoint,
SubPagesEndpoint,
PageAssetEndpoint,
)
@@ -130,4 +131,14 @@ urlpatterns = [
SubPagesEndpoint.as_view(),
name="sub-page",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/attachments/",
PageAssetEndpoint.as_view(),
name="page-assets",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/attachments/<str:workspace_id>/<str:asset_key>/",
PageAssetEndpoint.as_view(),
name="page-assets",
),
]

View File

@@ -14,6 +14,7 @@ from plane.app.views import (
ProjectPublicCoverImagesEndpoint,
ProjectDeployBoardViewSet,
UserProjectRolesEndpoint,
ProjectCoverImageEndpoint,
)
@@ -175,4 +176,14 @@ urlpatterns = [
),
name="project-deploy-board",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cover-image/<str:workspace_id>/<str:cover_image_key>/",
ProjectCoverImageEndpoint.as_view(),
name="project-cover-image",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cover-image/",
ProjectCoverImageEndpoint.as_view(),
name="project-cover-image",
),
]

View File

@@ -15,6 +15,10 @@ from plane.app.views import (
UserIssueCompletedGraphEndpoint,
UserWorkspaceDashboardEndpoint,
## End Workspaces
# Asset Endpoints ##
UserAvatarEndpoint,
UserCoverImageEndpoint,
## End Asset Endpoint ##
)
urlpatterns = [
@@ -95,5 +99,26 @@ urlpatterns = [
SetUserPasswordEndpoint.as_view(),
name="set-password",
),
## End User Graph
# User Assets
path(
"users/avatar/",
UserAvatarEndpoint.as_view(),
name="user-avatar",
),
path(
"users/avatar/<str:avatar_key>/",
UserAvatarEndpoint.as_view(),
name="user-avatar",
),
path(
"users/cover-image/",
UserCoverImageEndpoint.as_view(),
name="user-avatar",
),
path(
"users/cover-image/<str:cover_image_key>/",
UserCoverImageEndpoint.as_view(),
name="user-avatar",
),
## User Assets
]

View File

@@ -22,6 +22,7 @@ from plane.app.views import (
WorkspaceUserPropertiesEndpoint,
WorkspaceStatesEndpoint,
WorkspaceEstimatesEndpoint,
WorkspaceLogoEndpoint,
)
@@ -219,4 +220,14 @@ urlpatterns = [
WorkspaceEstimatesEndpoint.as_view(),
name="workspace-estimate",
),
path(
"workspaces/<str:slug>/logo/",
WorkspaceLogoEndpoint.as_view(),
name="workspace-logo",
),
path(
"workspaces/<str:slug>/logo/<str:workspace_id>/<str:logo_key>/",
WorkspaceLogoEndpoint.as_view(),
name="workspace-logo",
),
]

View File

@@ -12,12 +12,15 @@ from .project import (
ProjectPublicCoverImagesEndpoint,
ProjectDeployBoardViewSet,
UserProjectRolesEndpoint,
ProjectCoverImageEndpoint,
)
from .user import (
UserEndpoint,
UpdateUserOnBoardedEndpoint,
UpdateUserTourCompletedEndpoint,
UserActivityEndpoint,
UserAvatarEndpoint,
UserCoverImageEndpoint,
)
from .oauth import OauthEndpoint
@@ -49,6 +52,7 @@ from .workspace import (
WorkspaceUserPropertiesEndpoint,
WorkspaceStatesEndpoint,
WorkspaceEstimatesEndpoint,
WorkspaceLogoEndpoint,
)
from .state import StateViewSet
from .view import (
@@ -65,7 +69,7 @@ from .cycle import (
TransferCycleIssueEndpoint,
CycleUserPropertiesEndpoint,
)
from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
from .asset import FileAssetEndpoint, FileAssetViewSet
from .issue import (
IssueViewSet,
WorkSpaceIssuesEndpoint,
@@ -85,6 +89,7 @@ from .issue import (
IssueReactionViewSet,
IssueRelationViewSet,
IssueDraftViewSet,
CommentAssetEndpoint,
)
from .auth_extended import (
@@ -137,6 +142,7 @@ from .page import (
PageFavoriteViewSet,
PageLogEndpoint,
SubPagesEndpoint,
PageAssetEndpoint,
)
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
@@ -180,7 +186,4 @@ from .webhook import (
WebhookSecretRegenerateEndpoint,
)
from .dashboard import (
DashboardEndpoint,
WidgetsEndpoint
)
from .dashboard import DashboardEndpoint, WidgetsEndpoint

View File

@@ -61,40 +61,3 @@ class FileAssetViewSet(BaseViewSet):
file_asset.is_deleted = False
file_asset.save()
return Response(status=status.HTTP_204_NO_CONTENT)
class UserAssetsEndpoint(BaseAPIView):
parser_classes = (MultiPartParser, FormParser)
def get(self, request, asset_key):
files = FileAsset.objects.filter(
asset=asset_key, created_by=request.user
)
if files.exists():
serializer = FileAssetSerializer(
files, context={"request": request}
)
return Response(
{"data": serializer.data, "status": True},
status=status.HTTP_200_OK,
)
else:
return Response(
{"error": "Asset key does not exist", "status": False},
status=status.HTTP_200_OK,
)
def post(self, request):
serializer = FileAssetSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, asset_key):
file_asset = FileAsset.objects.get(
asset=asset_key, created_by=request.user
)
file_asset.is_deleted = True
file_asset.save()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -47,10 +47,10 @@ from plane.db.models import (
Issue,
CycleFavorite,
IssueLink,
IssueAttachment,
Label,
CycleUserProperties,
IssueSubscriber,
FileAsset,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
@@ -611,7 +611,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
attachment_count=FileAsset.objects.filter(
issue=OuterRef("id")
)
.order_by()

View File

@@ -32,7 +32,7 @@ from plane.db.models import (
Dashboard,
Project,
IssueLink,
IssueAttachment,
FileAsset,
IssueRelation,
)
from plane.app.serializers import (
@@ -117,8 +117,9 @@ def dashboard_assigned_issues(self, request, slug):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
@@ -246,8 +247,9 @@ def dashboard_created_issues(self, request, slug):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))

View File

@@ -19,7 +19,7 @@ from plane.db.models import (
Issue,
State,
IssueLink,
IssueAttachment,
FileAsset,
ProjectMember,
)
from plane.app.serializers import (
@@ -92,7 +92,7 @@ class InboxIssueViewSet(BaseViewSet):
Issue.objects.filter(
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
issue_inbox__inbox_id=self.kwargs.get("inbox_id")
issue_inbox__inbox_id=self.kwargs.get("inbox_id"),
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
@@ -112,8 +112,9 @@ class InboxIssueViewSet(BaseViewSet):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
@@ -131,8 +132,14 @@ class InboxIssueViewSet(BaseViewSet):
def list(self, request, slug, project_id, inbox_id):
filters = issue_filters(request.query_params, "GET")
issue_queryset = self.get_queryset().filter(**filters).order_by("issue_inbox__snoozed_till", "issue_inbox__status")
issues_data = IssueSerializer(issue_queryset, expand=self.expand, many=True).data
issue_queryset = (
self.get_queryset()
.filter(**filters)
.order_by("issue_inbox__snoozed_till", "issue_inbox__status")
)
issues_data = IssueSerializer(
issue_queryset, expand=self.expand, many=True
).data
return Response(
issues_data,
status=status.HTTP_200_OK,
@@ -199,8 +206,8 @@ class InboxIssueViewSet(BaseViewSet):
source=request.data.get("source", "in-app"),
)
issue = (self.get_queryset().filter(pk=issue.id).first())
serializer = IssueSerializer(issue ,expand=self.expand)
issue = self.get_queryset().filter(pk=issue.id).first()
serializer = IssueSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
def partial_update(self, request, slug, project_id, inbox_id, issue_id):
@@ -320,20 +327,23 @@ class InboxIssueViewSet(BaseViewSet):
if state is not None:
issue.state = state
issue.save()
issue = (self.get_queryset().filter(pk=issue_id).first())
issue = self.get_queryset().filter(pk=issue_id).first()
serializer = IssueSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
else:
issue = (self.get_queryset().filter(pk=issue_id).first())
serializer = IssueSerializer(issue ,expand=self.expand)
issue = self.get_queryset().filter(pk=issue_id).first()
serializer = IssueSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, inbox_id, issue_id):
issue = self.get_queryset().filter(pk=issue_id).first()
serializer = IssueSerializer(issue, expand=self.expand,)
serializer = IssueSerializer(
issue,
expand=self.expand,
)
return Response(serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, slug, project_id, inbox_id, issue_id):

View File

@@ -4,7 +4,7 @@ import random
from itertools import chain
# Django imports
from django.db import models
from django.http import HttpResponseRedirect
from django.utils import timezone
from django.db.models import (
Prefetch,
@@ -29,7 +29,7 @@ from django.db import IntegrityError
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
# Module imports
from . import BaseViewSet, BaseAPIView, WebhookMixin
@@ -43,13 +43,13 @@ from plane.app.serializers import (
IssueFlatSerializer,
IssueLinkSerializer,
IssueLiteSerializer,
IssueAttachmentSerializer,
IssueSubscriberSerializer,
ProjectMemberLiteSerializer,
IssueReactionSerializer,
CommentReactionSerializer,
IssueRelationSerializer,
RelatedIssueSerializer,
FileAssetSerializer,
)
from plane.app.permissions import (
ProjectEntityPermission,
@@ -58,6 +58,7 @@ from plane.app.permissions import (
ProjectLitePermission,
)
from plane.db.models import (
Workspace,
Project,
Issue,
IssueActivity,
@@ -65,7 +66,7 @@ from plane.db.models import (
IssueProperty,
Label,
IssueLink,
IssueAttachment,
FileAsset,
State,
IssueSubscriber,
ProjectMember,
@@ -75,10 +76,12 @@ from plane.db.models import (
IssueVote,
IssueRelation,
ProjectPublicMember,
FileAsset,
)
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
from collections import defaultdict
@@ -128,8 +131,9 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
@@ -372,8 +376,9 @@ class UserWorkSpaceIssues(BaseAPIView):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
@@ -792,8 +797,9 @@ class SubIssuesEndpoint(BaseAPIView):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
@@ -1010,17 +1016,24 @@ class BulkCreateIssueLabelsEndpoint(BaseAPIView):
class IssueAttachmentEndpoint(BaseAPIView):
serializer_class = IssueAttachmentSerializer
permission_classes = [
ProjectEntityPermission,
]
model = IssueAttachment
parser_classes = (MultiPartParser, FormParser)
parser_classes = (
MultiPartParser,
FormParser,
JSONParser,
)
def post(self, request, slug, project_id, issue_id):
serializer = IssueAttachmentSerializer(data=request.data)
serializer = FileAssetSerializer(data=request.data)
workspace = Workspace.objects.get(slug=slug)
if serializer.is_valid():
serializer.save(project_id=project_id, issue_id=issue_id)
serializer.save(
workspace=workspace,
project_id=project_id,
entity_identifier=issue_id,
)
issue_activity.delay(
type="attachment.activity.created",
requested_data=None,
@@ -1038,10 +1051,19 @@ class IssueAttachmentEndpoint(BaseAPIView):
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, pk):
issue_attachment = IssueAttachment.objects.get(pk=pk)
issue_attachment.asset.delete(save=False)
issue_attachment.delete()
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,
@@ -1053,14 +1075,34 @@ class IssueAttachmentEndpoint(BaseAPIView):
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):
issue_attachments = IssueAttachment.objects.filter(
issue_id=issue_id, workspace__slug=slug, project_id=project_id
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 = IssueAttachmentSerializer(issue_attachments, many=True)
serializer = FileAssetSerializer(issue_attachments, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -1084,7 +1126,7 @@ class IssueArchiveViewSet(BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@@ -1092,8 +1134,9 @@ class IssueArchiveViewSet(BaseViewSet):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
@@ -1131,10 +1174,7 @@ class IssueArchiveViewSet(BaseViewSet):
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
self.get_queryset()
.filter(**filters)
)
issue_queryset = self.get_queryset().filter(**filters)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
@@ -1579,15 +1619,17 @@ class IssueRelationViewSet(BaseViewSet):
issue_relation = IssueRelation.objects.bulk_create(
[
IssueRelation(
issue_id=issue
if relation_type == "blocking"
else issue_id,
related_issue_id=issue_id
if relation_type == "blocking"
else issue,
relation_type="blocked_by"
if relation_type == "blocking"
else relation_type,
issue_id=(
issue if relation_type == "blocking" else issue_id
),
related_issue_id=(
issue_id if relation_type == "blocking" else issue
),
relation_type=(
"blocked_by"
if relation_type == "blocking"
else relation_type
),
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
@@ -1689,8 +1731,9 @@ class IssueDraftViewSet(BaseViewSet):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
@@ -1727,10 +1770,7 @@ class IssueDraftViewSet(BaseViewSet):
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
self.get_queryset()
.filter(**filters)
)
issue_queryset = self.get_queryset().filter(**filters)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
@@ -1893,3 +1933,70 @@ class IssueDraftViewSet(BaseViewSet):
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(status=status.HTTP_204_NO_CONTENT)
class CommentAssetEndpoint(BaseAPIView):
permission_classes = [
ProjectLitePermission,
]
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)

View File

@@ -37,7 +37,7 @@ from plane.db.models import (
ModuleLink,
ModuleFavorite,
IssueLink,
IssueAttachment,
FileAsset,
IssueSubscriber,
ModuleUserProperties,
)
@@ -331,17 +331,16 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
ProjectEntityPermission,
]
def get_queryset(self):
return (
Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
issue_module__module_id=self.kwargs.get("module_id")
issue_module__module_id=self.kwargs.get("module_id"),
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("labels", "assignees")
.prefetch_related('issue_module__module')
.prefetch_related("issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
@@ -350,8 +349,9 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
@@ -420,10 +420,9 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
)
for issue in issues
]
issues = (self.get_queryset().filter(pk__in=issues))
serializer = IssueSerializer(issues , many=True)
issues = self.get_queryset().filter(pk__in=issues)
serializer = IssueSerializer(issues, many=True)
return Response(serializer.data, status=status.HTTP_201_CREATED)
# create multiple module inside an issue
def create_issue_modules(self, request, slug, project_id, issue_id):
@@ -466,11 +465,10 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
for module in modules
]
issue = (self.get_queryset().filter(pk=issue_id).first())
issue = self.get_queryset().filter(pk=issue_id).first()
serializer = IssueSerializer(issue)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def destroy(self, request, slug, project_id, module_id, issue_id):
module_issue = ModuleIssue.objects.get(
workspace__slug=slug,
@@ -484,7 +482,9 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps({"module_name": module_issue.module.name}),
current_instance=json.dumps(
{"module_name": module_issue.module.name}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),

View File

@@ -4,23 +4,34 @@ from datetime import date, datetime, timedelta
# Django imports
from django.db import connection
from django.db.models import Exists, OuterRef, Q
from django.utils import timezone
from django.http import HttpResponseRedirect
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import (IssueLiteSerializer, PageFavoriteSerializer,
PageLogSerializer, PageSerializer,
SubPageSerializer)
from plane.db.models import (Issue, IssueActivity, IssueAssignee, Page,
PageFavorite, PageLog, ProjectMember)
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
# Module imports
from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import (
PageFavoriteSerializer,
PageLogSerializer,
PageSerializer,
SubPageSerializer,
FileAssetSerializer,
)
from plane.db.models import (
Workspace,
Page,
PageFavorite,
PageLog,
ProjectMember,
FileAsset,
)
from .base import BaseAPIView, BaseViewSet
from plane.utils.presigned_url_generator import generate_download_presigned_url
def unarchive_archive_page_and_descendants(page_id, archived_at):
# Your SQL query
@@ -348,3 +359,70 @@ class SubPagesEndpoint(BaseAPIView):
return Response(
SubPageSerializer(pages, many=True).data, status=status.HTTP_200_OK
)
class PageAssetEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
parser_classes = (
MultiPartParser,
FormParser,
JSONParser,
)
def post(self, request, slug, project_id, page_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="page",
entity_identifier=page_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, page_id, workspace_id, asset_key
):
key = f"{workspace_id}/{asset_key}"
asset = FileAsset.objects.get(
asset=key,
entity_identifier=page_id,
entity_type="page",
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,
page_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
page_assets = FileAsset.objects.filter(
entity_type="page",
entity_identifier=page_id,
workspace__slug=slug,
project_id=project_id,
)
serializer = FileAssetSerializer(page_assets, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@@ -18,12 +18,13 @@ from django.db.models import (
from django.core.validators import validate_email
from django.conf import settings
from django.utils import timezone
from django.http import HttpResponseRedirect
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework import serializers
from rest_framework.permissions import AllowAny
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
# Module imports
from .base import BaseViewSet, BaseAPIView, WebhookMixin
@@ -37,6 +38,8 @@ from plane.app.serializers import (
ProjectDeployBoardSerializer,
ProjectMemberAdminSerializer,
ProjectMemberRoleSerializer,
ProjectLiteSerializer,
FileAssetSerializer,
)
from plane.app.permissions import (
@@ -62,10 +65,10 @@ from plane.db.models import (
Inbox,
ProjectDeployBoard,
IssueProperty,
FileAsset,
)
from plane.bgtasks.project_invitation_task import project_invitation
from plane.utils.presigned_url_generator import generate_download_presigned_url
class ProjectViewSet(WebhookMixin, BaseViewSet):
serializer_class = ProjectListSerializer
@@ -1138,3 +1141,49 @@ class UserProjectRolesEndpoint(BaseAPIView):
for member in project_members
}
return Response(project_members, status=status.HTTP_200_OK)
class ProjectCoverImageEndpoint(BaseAPIView):
parser_classes = (
MultiPartParser,
FormParser,
JSONParser,
)
def get_permissions(self):
if self.request.method == "POST" or self.request.method == "DELETE":
return [
IsAuthenticated(),
]
return [
AllowAny(),
]
def get(self, request, slug, project_id, workspace_id, cover_image_key):
key = f"{workspace_id}/{cover_image_key}"
url = generate_download_presigned_url(
key=key,
host=request.get_host(),
scheme=request.scheme,
)
return HttpResponseRedirect(url)
def post(self, request, slug, project_id):
serializer = FileAssetSerializer(data=request.data)
workspace = Workspace.objects.get(slug=slug)
if serializer.is_valid():
serializer.save(workspace=workspace)
project = Project.objects.get(pk=project_id)
project.cover_image = f"/api/workspaces/{slug}/projects/{project_id}/cover-image/{serializer.data['asset']}/"
project.save()
project_serializer = ProjectLiteSerializer(project)
return Response(project_serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, project_id, workspace_id, cover_image_key):
key = f"{workspace_id}/{cover_image_key}"
file_asset = FileAsset.objects.get(asset=key)
file_asset.is_deleted = True
file_asset.save()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -1,7 +1,16 @@
# Python imports
import requests
# Django imports
from django.http import HttpResponseRedirect
from django.conf import settings
# Third party imports
import boto3
from rest_framework.response import Response
from rest_framework import status
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
from rest_framework.permissions import AllowAny, IsAuthenticated
# Module imports
from plane.app.serializers import (
@@ -9,13 +18,14 @@ from plane.app.serializers import (
IssueActivitySerializer,
UserMeSerializer,
UserMeSettingsSerializer,
FileAssetSerializer,
)
from plane.app.views.base import BaseViewSet, BaseAPIView
from plane.db.models import User, IssueActivity, WorkspaceMember, ProjectMember
from plane.db.models import User, IssueActivity, WorkspaceMember, ProjectMember, FileAsset
from plane.license.models import Instance, InstanceAdmin
from plane.utils.paginator import BasePaginator
from plane.utils.presigned_url_generator import generate_download_presigned_url
from django.db.models import Q, F, Count, Case, When, IntegerField
@@ -177,3 +187,92 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator):
issue_activities, many=True
).data,
)
class UserAvatarEndpoint(BaseAPIView):
parser_classes = (
MultiPartParser,
FormParser,
JSONParser,
)
def get_permissions(self):
if self.request.method == "POST" or self.request.method == "DELETE":
return [
IsAuthenticated(),
]
return [
AllowAny(),
]
def get(self, request, avatar_key):
url = generate_download_presigned_url(
key=avatar_key,
host=request.get_host(),
scheme=request.scheme,
)
return HttpResponseRedirect(url)
def post(self, request):
serializer = FileAssetSerializer(data=request.data)
if serializer.is_valid():
# Get the workspace
serializer.save()
user = request.user
user.avatar = "/api/users/avatar/" + serializer.data["asset"] + "/"
user.save()
user_serializer = UserMeSerializer(user)
return Response(user_serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, avatar_key):
file_asset = FileAsset.objects.get(asset=avatar_key)
file_asset.is_deleted = True
file_asset.save()
return Response(status=status.HTTP_204_NO_CONTENT)
class UserCoverImageEndpoint(BaseAPIView):
parser_classes = (
MultiPartParser,
FormParser,
JSONParser,
)
def get_permissions(self):
if self.request.method == "POST" or self.request.method == "DELETE":
return [
IsAuthenticated(),
]
return [
AllowAny(),
]
def get(self, request, cover_image_key):
url = generate_download_presigned_url(
key=cover_image_key,
host=request.get_host(),
scheme=request.scheme,
)
return HttpResponseRedirect(url)
def post(self, request):
serializer = FileAssetSerializer(data=request.data)
if serializer.is_valid():
# Get the workspace
serializer.save()
user = request.user
user.avatar = "/api/users/cover-image/" + serializer.data["asset"] + "/"
user.save()
user_serializer = UserMeSerializer(user)
return Response(user_serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, cover_image_key):
file_asset = FileAsset.objects.get(asset=cover_image_key)
file_asset.is_deleted = True
file_asset.save()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -41,7 +41,7 @@ from plane.db.models import (
IssueViewFavorite,
IssueReaction,
IssueLink,
IssueAttachment,
FileAsset,
IssueSubscriber,
)
from plane.utils.issue_filters import issue_filters
@@ -130,8 +130,9 @@ class GlobalViewIssuesViewSet(BaseViewSet):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))

View File

@@ -25,11 +25,13 @@ from django.db.models import (
)
from django.db.models.functions import ExtractWeek, Cast, ExtractDay
from django.db.models.fields import DateField
from django.http import HttpResponseRedirect
# Third party modules
from rest_framework import status
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
# Module imports
from plane.app.serializers import (
@@ -49,6 +51,7 @@ from plane.app.serializers import (
WorkspaceEstimateSerializer,
StateSerializer,
LabelSerializer,
FileAssetSerializer,
)
from plane.app.views.base import BaseAPIView
from . import BaseViewSet
@@ -63,7 +66,7 @@ from plane.db.models import (
Issue,
WorkspaceTheme,
IssueLink,
IssueAttachment,
FileAsset,
IssueSubscriber,
Project,
Label,
@@ -73,6 +76,7 @@ from plane.db.models import (
WorkspaceUserProperties,
Estimate,
EstimatePoint,
FileAsset,
)
from plane.app.permissions import (
WorkSpaceBasePermission,
@@ -84,6 +88,7 @@ from plane.app.permissions import (
)
from plane.bgtasks.workspace_invitation_task import workspace_invitation
from plane.utils.issue_filters import issue_filters
from plane.utils.presigned_url_generator import generate_download_presigned_url
from plane.bgtasks.event_tracking_task import workspace_invite_event
@@ -1355,8 +1360,9 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
@@ -1524,3 +1530,52 @@ class WorkspaceUserPropertiesEndpoint(BaseAPIView):
)
serializer = WorkspaceUserPropertiesSerializer(workspace_properties)
return Response(serializer.data, status=status.HTTP_200_OK)
class WorkspaceLogoEndpoint(BaseAPIView):
parser_classes = (
MultiPartParser,
FormParser,
JSONParser,
)
def get_permissions(self):
if self.request.method == "POST" or self.request.method == "DELETE":
return [
IsAuthenticated(),
]
return [
AllowAny(),
]
def get(self, request, slug, workspace_id, logo_key):
key = f"{workspace_id}/{logo_key}"
url = generate_download_presigned_url(
key=key,
host=request.get_host(),
scheme=request.scheme,
)
return HttpResponseRedirect(url)
def post(self, request, slug):
serializer = FileAssetSerializer(data=request.data)
workspace = Workspace.objects.get(slug=slug)
if serializer.is_valid():
serializer.save(workspace=workspace)
workspace.logo = (
f"/api/workspaces/{slug}/logo/{serializer.data['asset']}/"
)
workspace.save()
workspace_serializer = WorkSpaceSerializer(workspace)
return Response(
workspace_serializer.data, status=status.HTTP_201_CREATED
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, slug, workspace_id, logo_key):
key = f"{workspace_id}/{logo_key}"
file_asset = FileAsset.objects.get(asset=key)
file_asset.is_deleted = True
file_asset.save()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -11,32 +11,18 @@ from django.conf import settings
class Command(BaseCommand):
help = "Create the default bucket for the instance"
def set_bucket_public_policy(self, s3_client, bucket_name):
public_policy = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject"],
"Resource": [f"arn:aws:s3:::{bucket_name}/*"],
}
],
}
def set_bucket_private_policy(self, s3_client, bucket_name):
try:
s3_client.put_bucket_policy(
Bucket=bucket_name, Policy=json.dumps(public_policy)
)
s3_client.delete_bucket_policy(Bucket=bucket_name)
self.stdout.write(
self.style.SUCCESS(
f"Public read access policy set for bucket '{bucket_name}'."
f"Public access policy removed for bucket '{bucket_name}', bucket is now private."
)
)
except ClientError as e:
self.stdout.write(
self.style.ERROR(
f"Error setting public read access policy: {e}"
f"Error removing public access policy: {e}"
)
)
@@ -58,7 +44,7 @@ class Command(BaseCommand):
# Check if the bucket exists
s3_client.head_bucket(Bucket=bucket_name)
self.set_bucket_public_policy(s3_client, bucket_name)
self.set_bucket_private_policy(s3_client, bucket_name)
except ClientError as e:
error_code = int(e.response["Error"]["Code"])
bucket_name = settings.AWS_STORAGE_BUCKET_NAME
@@ -76,7 +62,6 @@ class Command(BaseCommand):
f"Bucket '{bucket_name}' created successfully."
)
)
self.set_bucket_public_policy(s3_client, bucket_name)
except ClientError as create_error:
self.stdout.write(
self.style.ERROR(

View File

@@ -0,0 +1,248 @@
# Generated by Django 4.2.7 on 2024-01-31 13:34
import uuid
from django.db import migrations, models
from django.conf import settings
import django.db.models
import plane.db.models.asset
def update_user_urls(apps, schema_editor):
# Check if the app is using minio or s3
if settings.USE_MINIO:
prefix1 = (
f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/"
)
prefix2 = prefix1
else:
prefix1 = f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/"
prefix2 = (
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com/"
)
User = apps.get_model("db", "User")
bulk_users = []
# Loop through all the users and update the cover image
for user in User.objects.all():
# prefix 1
if user.avatar and (user.avatar.startswith(prefix1)):
avatar_key = user.avatar
user.avatar = (
"/api/users/avatar/" + avatar_key[len(prefix1) :] + "/"
)
bulk_users.append(user)
# prefix 2
if (
not settings.USE_MINIO
and user.avatar
and user.avatar.startswith(prefix2)
):
avatar_key = user.avatar
user.avatar = (
"/api/users/avatar/" + avatar_key[len(prefix2) :] + "/"
)
bulk_users.append(user)
# prefix 1
if user.cover_image and (user.cover_image.startswith(prefix1)):
cover_image_key = user.cover_image
user.cover_image = (
"/api/users/cover-image/"
+ cover_image_key[len(prefix1) :]
+ "/"
)
bulk_users.append(user)
# prefix 2
if (
not settings.USE_MINIO
and user.cover_image
and user.cover_image.startswith(prefix2)
):
cover_image_key = user.cover_image
user.cover_image = (
"/api/users/cover-image/"
+ cover_image_key[len(prefix2) :]
+ "/"
)
bulk_users.append(user)
User.objects.bulk_update(
bulk_users, ["avatar", "cover_image"], batch_size=100
)
def update_workspace_urls(apps, schema_editor):
# Check if the app is using minio or s3
if settings.USE_MINIO:
prefix1 = (
f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/"
)
prefix2 = prefix1
else:
prefix1 = f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/"
prefix2 = (
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com/"
)
Workspace = apps.get_model("db", "Workspace")
bulk_workspaces = []
# Loop through all the users and update the cover image
for workspace in Workspace.objects.all():
# prefix 1
if workspace.logo and (workspace.logo.startswith(prefix1)):
logo_key = workspace.logo
workspace.logo = f"/api/workspaces/{workspace.slug}/logo/{logo_key[len(prefix1) :]}/"
bulk_workspaces.append(workspace)
# prefix 2
if (
not settings.USE_MINIO
and workspace.logo
and (workspace.logo.startswith(prefix2))
):
logo_key = workspace.logo
workspace.logo = f"/api/workspaces/{workspace.slug}/logo/{logo_key[len(prefix2) :]}/"
bulk_workspaces.append(workspace)
Workspace.objects.bulk_update(bulk_workspaces, ["logo"], batch_size=100)
def update_project_urls(apps, schema_editor):
file_assets = {}
# Check if the app is using minio or s3
if settings.USE_MINIO:
prefix1 = (
f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/"
)
prefix2 = prefix1
else:
prefix1 = f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/"
prefix2 = (
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com/"
)
Project = apps.get_model("db", "Project")
bulk_projects = []
# Loop through all the users and update the cover image
for project in Project.objects.all():
# prefix 1
if project.cover_image and (project.cover_image.startswith(prefix1)):
cover_image_key = project.cover_image
project.cover_image = f"/api/workspaces/{project.workspace.slug}/projects/{project.id}/cover-image/{cover_image_key[len(prefix1) :]}/"
file_assets[cover_image_key[len(prefix1) :]] = str(project.id)
bulk_projects.append(project)
# prefix 2
if (
not settings.USE_MINIO
and project.cover_image
and (project.cover_image.startswith(prefix2))
):
cover_image_key = project.cover_image
project.cover_image = f"/api/workspaces/{project.workspace.slug}/projects/{project.id}/cover-image/{cover_image_key[len(prefix2) :]}/"
file_assets[cover_image_key[len(prefix2) :]] = str(project.id)
bulk_projects.append(project)
Project.objects.bulk_update(bulk_projects, ["cover_image"], batch_size=100)
FileAsset = apps.get_model("db", "FileAsset")
bulk_assets = []
for asset in FileAsset.objects.filter(asset__in=file_assets.keys()):
asset.project_id = file_assets[str(asset.asset)]
bulk_assets.append(asset)
FileAsset.objects.bulk_update(
bulk_assets,
[
"project_id",
],
batch_size=100,
)
return
class Migration(migrations.Migration):
dependencies = [
("db", "0058_alter_moduleissue_issue_and_more"),
]
operations = [
migrations.AlterField(
model_name="fileasset",
name="asset",
field=models.FileField(
storage=plane.settings.storage.S3PrivateBucketStorage(),
upload_to=plane.db.models.asset.get_upload_path,
validators=[plane.db.models.asset.file_size],
),
),
migrations.RemoveField(
model_name="issueactivity",
name="attachments",
),
migrations.RemoveField(
model_name="issuecomment",
name="attachments",
),
migrations.AlterField(
model_name="integration",
name="avatar_url",
field=models.CharField(blank=True, null=True),
),
migrations.AlterField(
model_name="project",
name="cover_image",
field=models.CharField(blank=True, max_length=800, null=True),
),
migrations.AlterField(
model_name="user",
name="cover_image",
field=models.CharField(blank=True, max_length=800, null=True),
),
migrations.AlterField(
model_name="workspace",
name="logo",
field=models.CharField(blank=True, null=True, verbose_name="Logo"),
),
migrations.AddField(
model_name="fileasset",
name="size",
field=models.PositiveBigIntegerField(null=True),
),
migrations.AddField(
model_name="fileasset",
name="entity_identifier",
field=models.UUIDField(null=True),
),
migrations.AddField(
model_name="fileasset",
name="entity_type",
field=models.CharField(
choices=[
("issue_attachment", "Issue Attachment"),
("issue_description", "Issue Description"),
("comment", "Comment"),
("page", "Page"),
],
null=True,
),
),
migrations.AddField(
model_name="fileasset",
name="project",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="assets",
to="db.project",
),
),
migrations.RunPython(update_user_urls),
migrations.RunPython(update_workspace_urls),
migrations.RunPython(update_project_urls),
]

View File

@@ -0,0 +1,237 @@
# Generated by Django 4.2.7 on 2024-02-02 07:23
from django.db import migrations, models
from django.conf import settings
import django.db.models.deletion
# Third party imports
from bs4 import BeautifulSoup
def convert_issue_description_image_sources(apps, schema_editor):
file_assets = {}
if settings.USE_MINIO:
prefix1 = (
f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/"
)
prefix2 = prefix1
else:
prefix1 = f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/"
prefix2 = (
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com/"
)
Issue = apps.get_model("db", "Issue")
bulk_issues = []
for issue in Issue.objects.all():
# Parse the html
soup = BeautifulSoup(issue.description_html, "lxml")
img_tags = soup.find_all("img")
for img in img_tags:
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): ]}"
)
file_assets[src[len(prefix1) :]] = {
"project_id": str(issue.project_id),
"issue_id": str(issue.id),
}
issue.description_html = str(soup)
bulk_issues.append(issue)
# 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): ]}"
)
file_assets[src[len(prefix2) :]] = {
"project_id": str(issue.project_id),
"issue_id": str(issue.id),
}
issue.description_html = str(soup)
bulk_issues.append(issue)
# Update the issue description htmls
Issue.objects.bulk_update(
bulk_issues, ["description_html"], batch_size=1000
)
# Update file assets
FileAsset = apps.get_model("db", "FileAsset")
bulk_assets = []
for asset in FileAsset.objects.filter(asset__in=file_assets.keys()):
asset.project_id = file_assets[str(asset.asset)]["project_id"]
asset.entity_identifier = file_assets[str(asset.asset)]["issue_id"]
asset.entity_type = "issue_description"
bulk_assets.append(asset)
FileAsset.objects.bulk_update(
bulk_assets,
[
"project_id",
"entity_identifier",
"entity_type",
],
batch_size=100,
)
return
def convert_page_image_sources(apps, schema_editor):
file_assets = {}
if settings.USE_MINIO:
prefix1 = (
f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/"
)
prefix2 = prefix1
else:
prefix1 = f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/"
prefix2 = (
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com/"
)
Page = apps.get_model("db", "Page")
FileAsset = apps.get_model("db", "FileAsset")
bulk_pages = []
bulk_assets = {}
for page in Page.objects.all():
# Parse the html
soup = BeautifulSoup(page.description_html, "lxml")
img_tags = soup.find_all("img")
for img in img_tags:
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): ]}/"
)
file_assets[src[len(prefix1) :]] = {
"project_id": str(page.project_id),
"page_id": str(page.id),
}
page.description_html = str(soup)
bulk_pages.append(page)
# 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): ]}/"
)
file_assets[src[len(prefix2) :]] = {
"project_id": str(page.project_id),
"page_id": str(page.id),
}
page.description_html = str(soup)
bulk_pages.append(page)
Page.objects.bulk_update(bulk_pages, ["description_html"], batch_size=1000)
# Update file assets
FileAsset = apps.get_model("db", "FileAsset")
bulk_assets = []
for asset in FileAsset.objects.filter(asset__in=file_assets.keys()):
asset.project_id = file_assets[str(asset.asset)]["project_id"]
asset.entity_identifier = file_assets[str(asset.asset)]["page_id"]
asset.entity_type = "page"
bulk_assets.append(asset)
FileAsset.objects.bulk_update(
bulk_assets,
[
"project_id",
"entity_identifier",
"entity_type",
],
batch_size=100,
)
return
def convert_comment_image_sources(apps, schema_editor):
file_assets = {}
if settings.USE_MINIO:
prefix1 = (
f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/"
)
prefix2 = prefix1
else:
prefix1 = f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/"
prefix2 = (
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com/"
)
IssueComment = apps.get_model("db", "IssueComment")
bulk_comments = []
bulk_assets = {}
for comment in IssueComment.objects.all():
# Parse the html
soup = BeautifulSoup(comment.comment_html, "lxml")
img_tags = soup.find_all("img")
for img in img_tags:
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): ]}/"
)
file_assets[src[len(prefix1) :]] = {
"project_id": str(comment.project_id),
"comment_id": str(comment.id),
}
comment.comment_html = str(soup)
bulk_comments.append(comment)
# 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): ]}/"
)
file_assets[src[len(prefix2) :]] = {
"project_id": str(comment.project_id),
"comment_id": str(comment.id),
}
comment.comment_html = str(soup)
bulk_comments.append(comment)
IssueComment.objects.bulk_update(bulk_comments, ["comment_html"], batch_size=1000)
# Update file assets
FileAsset = apps.get_model("db", "FileAsset")
bulk_assets = []
for asset in FileAsset.objects.filter(asset__in=file_assets.keys()):
asset.project_id = file_assets[str(asset.asset)]["project_id"]
asset.entity_identifier = file_assets[str(asset.asset)]["comment_id"]
asset.entity_type = "comment"
bulk_assets.append(asset)
FileAsset.objects.bulk_update(
bulk_assets,
[
"project_id",
"entity_identifier",
"entity_type",
],
batch_size=100,
)
return
class Migration(migrations.Migration):
dependencies = [
("db", "0059_auto_20240131_1334"),
]
operations = [
migrations.RunPython(convert_issue_description_image_sources),
migrations.RunPython(convert_page_image_sources),
migrations.RunPython(convert_comment_image_sources),
]

View File

@@ -0,0 +1,44 @@
# Generated by Django 4.2.7 on 2024-02-05 06:42
from django.db import migrations, models
import django.db.models.deletion
def create_attachment_assets(apps, schema_editor):
bulk_assets = []
issue_attachments = {}
FileAsset = apps.get_model("db", "FileAsset")
IssueAttachment = apps.get_model("db", "IssueAttachment")
for issue_attachment in IssueAttachment.objects.values():
bulk_assets.append(
FileAsset(
workspace_id=issue_attachment["workspace_id"],
project_id=issue_attachment["project_id"],
entity_identifier=issue_attachment["issue_id"],
entity_type="issue_attachment",
asset=issue_attachment["asset"],
attributes=issue_attachment["attributes"],
)
)
issue_attachments[str(issue_attachment["asset"])] = str(
issue_attachment["id"]
)
FileAsset.objects.bulk_create(bulk_assets, batch_size=100)
class Migration(migrations.Migration):
dependencies = [
("db", "0060_fileasset_size"),
]
operations = [
migrations.RunPython(create_attachment_assets),
migrations.DeleteModel(
name="IssueAttachment",
),
]

View File

@@ -37,7 +37,6 @@ from .issue import (
IssueMention,
IssueLink,
IssueSequence,
IssueAttachment,
IssueSubscriber,
IssueReaction,
CommentReaction,

View File

@@ -8,12 +8,12 @@ from django.conf import settings
# Module import
from . import BaseModel
from plane.settings.storage import S3PrivateBucketStorage
def get_upload_path(instance, filename):
if instance.workspace_id is not None:
return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
return f"user-{uuid4().hex}-{filename}"
return f"{instance.workspace.id}/{uuid4().hex}"
return f"user-{uuid4().hex}"
def file_size(value):
@@ -32,6 +32,7 @@ class FileAsset(BaseModel):
validators=[
file_size,
],
storage=S3PrivateBucketStorage(),
)
workspace = models.ForeignKey(
"db.Workspace",
@@ -39,7 +40,24 @@ class FileAsset(BaseModel):
null=True,
related_name="assets",
)
project = models.ForeignKey(
"db.Project",
on_delete=models.CASCADE,
null=True,
related_name="assets",
)
entity_type = models.CharField(
choices=(
("issue_attachment", "Issue Attachment"),
("issue_description", "Issue Description"),
("comment", "Comment"),
("page", "Page"),
),
null=True,
)
entity_identifier = models.UUIDField(null=True)
is_deleted = models.BooleanField(default=False)
size = models.PositiveBigIntegerField(null=True)
class Meta:
verbose_name = "File Asset"
@@ -49,3 +67,7 @@ class FileAsset(BaseModel):
def __str__(self):
return str(self.asset)
def save(self, *args, **kwargs):
self.size = self.asset.size
super(FileAsset, self).save(*args, **kwargs)

View File

@@ -29,7 +29,7 @@ class Integration(AuditModel):
redirect_url = models.TextField(blank=True)
metadata = models.JSONField(default=dict)
verified = models.BooleanField(default=False)
avatar_url = models.URLField(blank=True, null=True)
avatar_url = models.CharField(blank=True, null=True)
def __str__(self):
"""Return provider of the integration"""

View File

@@ -337,7 +337,7 @@ class IssueLink(ProjectBaseModel):
def get_upload_path(instance, filename):
return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
return f"{instance.workspace.id}/{uuid4().hex}"
def file_size(value):
@@ -345,29 +345,6 @@ def file_size(value):
if value.size > settings.FILE_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,
@@ -389,9 +366,6 @@ class IssueActivity(ProjectBaseModel):
)
comment = models.TextField(verbose_name="Comment", blank=True)
attachments = ArrayField(
models.URLField(), size=10, blank=True, default=list
)
issue_comment = models.ForeignKey(
"db.IssueComment",
on_delete=models.SET_NULL,
@@ -423,9 +397,6 @@ class IssueComment(ProjectBaseModel):
comment_stripped = models.TextField(verbose_name="Comment", blank=True)
comment_json = models.JSONField(blank=True, default=dict)
comment_html = models.TextField(blank=True, default="<p></p>")
attachments = ArrayField(
models.URLField(), size=10, blank=True, default=list
)
issue = models.ForeignKey(
Issue, on_delete=models.CASCADE, related_name="issue_comments"
)

View File

@@ -94,7 +94,7 @@ class Project(BaseModel):
issue_views_view = models.BooleanField(default=True)
page_view = models.BooleanField(default=True)
inbox_view = models.BooleanField(default=False)
cover_image = models.URLField(blank=True, null=True, max_length=800)
cover_image = models.CharField(blank=True, null=True, max_length=800)
estimate = models.ForeignKey(
"db.Estimate",
on_delete=models.SET_NULL,

View File

@@ -15,12 +15,17 @@ from django.db.models.signals import post_save
from django.conf import settings
from django.dispatch import receiver
from django.utils import timezone
from django.core.exceptions import ValidationError
# Third party imports
from sentry_sdk import capture_exception
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
# Module imports
from . import BaseModel
from plane.settings.storage import S3PrivateBucketStorage
def get_default_onboarding():
return {
@@ -49,7 +54,7 @@ class User(AbstractBaseUser, PermissionsMixin):
first_name = models.CharField(max_length=255, blank=True)
last_name = models.CharField(max_length=255, blank=True)
avatar = models.CharField(max_length=255, blank=True)
cover_image = models.URLField(blank=True, null=True, max_length=800)
cover_image = models.CharField(blank=True, null=True, max_length=800)
# tracking metrics
date_joined = models.DateTimeField(

View File

@@ -131,7 +131,7 @@ def slug_validator(value):
class Workspace(BaseModel):
name = models.CharField(max_length=80, verbose_name="Workspace Name")
logo = models.URLField(verbose_name="Logo", blank=True, null=True)
logo = models.CharField(verbose_name="Logo", blank=True, null=True)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,

View File

@@ -226,14 +226,14 @@ STORAGES = {
},
}
STORAGES["default"] = {
"BACKEND": "storages.backends.s3boto3.S3Boto3Storage",
"BACKEND": "plane.settings.storage.S3PrivateBucketStorage",
}
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key")
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key")
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads")
AWS_REGION = os.environ.get("AWS_REGION", "")
AWS_DEFAULT_ACL = "public-read"
AWS_QUERYSTRING_AUTH = False
AWS_QUERYSTRING_AUTH = True
AWS_S3_FILE_OVERWRITE = False
AWS_S3_ENDPOINT_URL = os.environ.get(
"AWS_S3_ENDPOINT_URL", None

View File

@@ -0,0 +1,9 @@
# Third party imports
from storages.backends.s3boto3 import S3Boto3Storage
class S3PrivateBucketStorage(S3Boto3Storage):
def url(self, name):
# Return an empty string or None, or implement custom logic here
return name

View File

@@ -22,7 +22,7 @@ from plane.db.models import (
CycleIssue,
ModuleIssue,
IssueLink,
IssueAttachment,
FileAsset,
IssueReaction,
CommentReaction,
IssueVote,
@@ -171,22 +171,6 @@ 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",
]
class IssueReactionSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
@@ -218,7 +202,6 @@ 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)
issue_reactions = IssueReactionSerializer(read_only=True, many=True)

View File

@@ -17,7 +17,7 @@ from plane.db.models import (
Issue,
State,
IssueLink,
IssueAttachment,
FileAsset,
ProjectDeployBoard,
)
from plane.app.serializers import (
@@ -95,8 +95,9 @@ class InboxIssuePublicViewSet(BaseViewSet):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))

View File

@@ -40,7 +40,7 @@ from plane.db.models import (
IssueComment,
Label,
IssueLink,
IssueAttachment,
FileAsset,
State,
ProjectMember,
IssueReaction,
@@ -567,8 +567,9 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))

View File

@@ -0,0 +1,59 @@
# Python imports
import uuid
import requests
# Django imports
from django.conf import settings
from django.http import StreamingHttpResponse
# Third party imports
import boto3
def get_file_streams(key, filename=uuid.uuid4().hex):
if settings.USE_MINIO:
s3 = boto3.client(
's3',
endpoint_url=settings.AWS_S3_ENDPOINT_URL,
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=boto3.session.Config(signature_version='s3v4'),
)
else:
s3 = boto3.client(
's3',
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=boto3.session.Config(signature_version='s3v4'),
)
presigned_url = s3.generate_presigned_url(
'get_object',
Params={
'Bucket': settings.AWS_STORAGE_BUCKET_NAME,
'Key': key,
},
ExpiresIn=3600,
)
# Fetch the object metadata to get the content type
metadata = s3.head_object(
Bucket=settings.AWS_STORAGE_BUCKET_NAME,
Key=key,
)
# Stream the file from the custom endpoint URL
def stream_file_from_url(url):
with requests.get(url, stream=True) as r:
r.raise_for_status()
for chunk in r.iter_content(chunk_size=8192):
if chunk: # filter out keep-alive new chunks
yield chunk
content_type = metadata['ContentType']
response = StreamingHttpResponse(stream_file_from_url(presigned_url), content_type=content_type)
response['Content-Disposition'] = f'inline; filename={filename}' # Adjust filename as needed
return response

View File

@@ -0,0 +1,63 @@
import boto3
from django.conf import settings
def generate_download_presigned_url(key, expiration=3600, host="localhost", scheme="http"):
"""
Generate a presigned URL to download an object from S3, dynamically setting
the Content-Disposition based on the file metadata.
"""
if settings.USE_MINIO:
s3_client = boto3.client(
"s3",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
region_name=settings.AWS_REGION,
endpoint_url=settings.AWS_S3_ENDPOINT_URL,
)
else:
s3_client = boto3.client(
"s3",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
region_name=settings.AWS_REGION,
)
# Fetch the object's metadata
metadata = s3_client.head_object(
Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=key
)
# Determine the content type
content_type = metadata.get("ContentType", "application/octet-stream")
# Example logic to determine Content-Disposition based on content_type or other criteria
if content_type.startswith("image/"):
disposition = "inline"
else:
disposition = "attachment"
# Optionally, use the file's original name from metadata, if available
file_name = key.split("/")[
-1
] # Basic way to extract file name
disposition += f'; filename="{file_name}"'
try:
response = s3_client.generate_presigned_url(
"get_object",
Params={
"Bucket": settings.AWS_STORAGE_BUCKET_NAME,
"Key": key,
"ResponseContentDisposition": disposition,
"ResponseContentType": content_type,
},
ExpiresIn=expiration,
)
# Manually replace with
url = response.replace(settings.AWS_S3_ENDPOINT_URL, f"{scheme}://{host}") if settings.USE_MINIO and response.startswith(settings.AWS_S3_ENDPOINT_URL) else response
return url
except Exception as e:
print(f"Error generating presigned download URL: {e}")
return None

View File

@@ -13,7 +13,8 @@ SENTRY_ENVIRONMENT=production
GOOGLE_CLIENT_ID=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
DOCKERIZED=1 # deprecated
# deprecated
DOCKERIZED=1
CORS_ALLOWED_ORIGINS=http://localhost
#DB SETTINGS
@@ -23,12 +24,12 @@ POSTGRES_USER=plane
POSTGRES_PASSWORD=plane
POSTGRES_DB=plane
PGDATA=/var/lib/postgresql/data
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${PGHOST}/${PGDATABASE}
DATABASE_URL=
# REDIS SETTINGS
REDIS_HOST=plane-redis
REDIS_PORT=6379
REDIS_URL=redis://${REDIS_HOST}:6379/
REDIS_URL=
# EMAIL SETTINGS
EMAIL_HOST=
@@ -39,6 +40,14 @@ EMAIL_FROM=Team Plane <team@mailer.plane.so>
EMAIL_USE_TLS=1
EMAIL_USE_SSL=0
# OPENAI SETTINGS
# deprecated
OPENAI_API_BASE=https://api.openai.com/v1
# deprecated
OPENAI_API_KEY=sk-
# deprecated
GPT_ENGINE=gpt-3.5-turbo
# LOGIN/SIGNUP SETTINGS
ENABLE_SIGNUP=1
ENABLE_EMAIL_PASSWORD=1

View File

@@ -16,6 +16,12 @@ http {
add_header Permissions-Policy "interest-cohort=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Host ${dollar}host;
add_header X-Real-IP ${dollar}remote_addr;
add_header X-Forwarded-For ${dollar}proxy_add_x_forwarded_for;
add_header X-Forwarded-Proto ${dollar}scheme;
location / {
proxy_pass http://web:3000/;
proxy_http_version 1.1;

View File

@@ -16,6 +16,11 @@ http {
add_header Permissions-Policy "interest-cohort=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Host ${dollar}host;
add_header X-Real-IP ${dollar}remote_addr;
add_header X-Forwarded-For ${dollar}proxy_add_x_forwarded_for;
add_header X-Forwarded-Proto ${dollar}scheme;
location / {
proxy_pass http://web:3000/;
}

View File

@@ -17,6 +17,7 @@ interface CustomEditorProps {
description_html: string;
};
deleteFile: DeleteImage;
getAsset: any;
cancelUploadImage?: () => any;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
setShouldShowAlert?: (showAlert: boolean) => void;
@@ -35,6 +36,7 @@ export const useEditor = ({
uploadFile,
deleteFile,
cancelUploadImage,
getAsset,
editorProps = {},
value,
rerenderOnPropsChange,
@@ -62,7 +64,8 @@ export const useEditor = ({
},
deleteFile,
restoreFile,
cancelUploadImage
cancelUploadImage,
getAsset
),
...extensions,
],
@@ -80,6 +83,7 @@ export const useEditor = ({
[rerenderOnPropsChange]
);
console.log("yoooooooooo", editor?.getHTML());
const editorRef: MutableRefObject<Editor | null> = useRef(null);
editorRef.current = editor;

View File

@@ -123,7 +123,7 @@ export const insertImageCommand = (
if (input.files?.length) {
const file = input.files[0];
const pos = editor.view.state.selection.from;
startImageUpload(file, editor.view, pos, uploadFile, setIsSubmitting);
startImageUpload(file, editor.view, pos, uploadFile, setIsSubmitting, editor);
}
};
input.click();

View File

@@ -11,7 +11,7 @@ interface EditorContentProps {
export const EditorContentWrapper = ({ editor, editorContentCustomClassNames = "", children }: EditorContentProps) => (
<div className={`contentEditor ${editorContentCustomClassNames}`}>
<EditorContent editor={editor} />
{editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} />}
{/* {editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} />} */}
{children}
</div>
);

View File

@@ -0,0 +1,347 @@
import { NodeViewWrapper, type NodeViewProps, ReactNodeViewRenderer, mergeAttributes } from "@tiptap/react";
import { type CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
import ImageExt from "@tiptap/extension-image";
import { DeleteImage } from "src/types/delete-image";
import { RestoreImage } from "src/types/restore-image";
import { onNodeDeleted, onNodeRestored } from "src/ui/plugins/delete-image";
import { UploadImagesPlugin } from "src/ui/plugins/upload-image";
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
interface ImageNode extends ProseMirrorNode {
attrs: {
src: string;
id: string;
};
}
const deleteKey = new PluginKey("delete-image");
const IMAGE_NODE_TYPE = "image";
const useEvent = <T extends (...args: any[]) => any>(handler: T): T => {
const handlerRef = useRef<T | null>(null);
useLayoutEffect(() => {
handlerRef.current = handler;
}, [handler]);
return useCallback((...args: Parameters<T>): ReturnType<T> => {
if (handlerRef.current === null) {
throw new Error("Handler is not assigned");
}
return handlerRef.current(...args);
}, []) as T;
};
const MIN_WIDTH = 60;
const BORDER_COLOR = "#0096fd";
export const ResizableImageTemplate = ({ node, updateAttributes, getAsset }: NodeViewProps & { getAsset: any }) => {
const containerRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
const [editing, setEditing] = useState(false);
const [resizingStyle, setResizingStyle] = useState<Pick<CSSProperties, "width"> | undefined>();
const [loading, setLoading] = useState(true);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setEditing(false);
}
};
document.addEventListener("click", handleClickOutside);
return () => {
document.removeEventListener("click", handleClickOutside);
};
}, [editing]);
const [src, setSrc] = useState("");
useEffect(() => {
const fetchImageBlob = async () => {
setLoading(true); // Start loading
try {
const blob = await getAsset?.(node.attrs.assetId);
const imageUrl = URL.createObjectURL(blob);
setSrc(imageUrl);
} catch (error) {
console.error("Error fetching image:", error);
} finally {
setLoading(false); // Stop loading regardless of the outcome
}
};
if (node.attrs.assetId) {
fetchImageBlob();
}
}, [node.attrs.assetId, getAsset]);
const handleMouseDown = useEvent((event: React.MouseEvent<HTMLDivElement>) => {
if (!imgRef.current) return;
event.preventDefault();
const direction = event.currentTarget.dataset.direction || "--";
const initialXPosition = event.clientX;
const currentWidth = imgRef.current.width;
let newWidth = currentWidth;
const transform = direction[1] === "w" ? -1 : 1;
const removeListeners = () => {
window.removeEventListener("mousemove", mouseMoveHandler);
window.removeEventListener("mouseup", removeListeners);
updateAttributes({ width: newWidth });
setResizingStyle(undefined);
};
const mouseMoveHandler = (event: MouseEvent) => {
newWidth = Math.max(currentWidth + transform * (event.clientX - initialXPosition), MIN_WIDTH);
setResizingStyle({ width: newWidth });
// If mouse is up, remove event listeners
if (!event.buttons) removeListeners();
};
window.addEventListener("mousemove", mouseMoveHandler);
window.addEventListener("mouseup", removeListeners);
});
const dragCornerButton = (direction: string) => (
<div
role="button"
tabIndex={0}
onMouseDown={handleMouseDown}
data-direction={direction}
style={{
position: "absolute",
height: "10px",
width: "10px",
backgroundColor: BORDER_COLOR,
...{ n: { top: 0 }, s: { bottom: 0 } }[direction[0]],
...{ w: { left: 0 }, e: { right: 0 } }[direction[1]],
cursor: `${direction}-resize`,
}}
/>
);
console.log("image node", loading);
return (
<NodeViewWrapper
ref={containerRef}
as="div"
className="image-component"
draggable={true}
data-drag-handle
onClick={() => setEditing(true)}
onBlur={() => setEditing(false)}
>
<div
style={{
overflow: "hidden",
position: "relative",
display: "inline-block",
// Weird! Basically tiptap/prose wraps this in a span and the line height causes an annoying buffer.
lineHeight: "0px",
}}
>
{loading ? (
<div className="flex justify-center items-center w-48 h-32 border border-custom-border-400">
{/* Example loading spinner using Tailwind CSS */}
<div role="status">
<svg
aria-hidden="true"
className="inline w-4 h-4 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span className="sr-only">Loading...</span>
</div>
</div>
) : (
<img
{...node.attrs}
src={src}
ref={imgRef}
style={{
...resizingStyle,
cursor: "default",
width: "35%%",
}}
alt=""
/>
)}
{editing && (
<>
{[
{ left: 0, top: 0, height: "100%", width: "1px" },
{ right: 0, top: 0, height: "100%", width: "1px" },
{ top: 0, left: 0, width: "100%", height: "1px" },
{ bottom: 0, left: 0, width: "100%", height: "1px" },
].map((style, i) => (
<div key={i} style={{ position: "absolute", backgroundColor: BORDER_COLOR, ...style }} />
))}
{dragCornerButton("nw")}
{dragCornerButton("ne")}
{dragCornerButton("sw")}
{dragCornerButton("se")}
</>
)}
</div>
</NodeViewWrapper>
);
};
export const ImageExtension = (
deleteImage: DeleteImage,
restoreFile: RestoreImage,
cancelUploadImage?: () => any,
getAsset?: any
) =>
ImageExt.extend({
addProseMirrorPlugins() {
return [
UploadImagesPlugin(cancelUploadImage),
new Plugin({
key: deleteKey,
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
const newImageSources = new Set<string>();
newState.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
newImageSources.add(node.attrs.src);
}
});
transactions.forEach((transaction) => {
// transaction could be a selection
if (!transaction.docChanged) return;
const removedImages: ImageNode[] = [];
// iterate through all the nodes in the old state
oldState.doc.descendants((oldNode) => {
// if the node is not an image, then return as no point in checking
if (oldNode.type.name !== IMAGE_NODE_TYPE) return;
// Check if the node has been deleted or replaced
if (!newImageSources.has(oldNode.attrs.src)) {
removedImages.push(oldNode as ImageNode);
}
});
removedImages.forEach(async (node) => {
const src = node.attrs.src;
this.storage.images.set(src, true);
await onNodeDeleted(src, deleteImage);
});
});
return null;
},
}),
new Plugin({
key: new PluginKey("imageRestoration"),
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
const oldImageSources = new Set<string>();
oldState.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
oldImageSources.add(node.attrs.src);
}
});
transactions.forEach((transaction) => {
if (!transaction.docChanged) return;
const addedImages: ImageNode[] = [];
newState.doc.descendants((node, pos) => {
if (node.type.name !== IMAGE_NODE_TYPE) return;
if (pos < 0 || pos > newState.doc.content.size) return;
if (oldImageSources.has(node.attrs.src)) return;
addedImages.push(node as ImageNode);
});
addedImages.forEach(async (image) => {
const wasDeleted = this.storage.images.get(image.attrs.src);
if (wasDeleted === undefined) {
this.storage.images.set(image.attrs.src, false);
} else if (wasDeleted === true) {
await onNodeRestored(image.attrs.src, restoreFile);
}
});
});
return null;
},
}),
];
},
onCreate(this) {
const imageSources = new Set<string>();
this.editor.state.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
imageSources.add(node.attrs.src);
}
});
imageSources.forEach(async (src) => {
try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
await restoreFile(assetUrlWithWorkspaceId);
} catch (error) {
console.error("Error restoring image: ", error);
}
});
},
// storage to keep track of image states Map<src, isDeleted>
addStorage() {
return {
images: new Map<string, boolean>(),
};
},
addNodeView() {
return ReactNodeViewRenderer((props: Object) => <ResizableImageTemplate {...props} getAsset={getAsset} />);
},
parseHTML() {
return [
{
tag: "image-component", // Assuming your images are represented by <img> tags with a specific attribute
getAttrs: (node: string | HTMLElement) => {
if (typeof node === "string") {
return null;
}
return {
assetId: node.getAttribute("assetId") || null,
src: node.getAttribute("src"),
alt: node.getAttribute("alt") || "",
title: node.getAttribute("title") || "",
};
},
},
];
},
draggable: true,
renderHTML({ HTMLAttributes }) {
return ["image-component", mergeAttributes(HTMLAttributes)];
},
addAttributes() {
return {
// ...this.parent?.(),
assetId: {
default: null,
},
width: {
default: "35%",
},
height: {
default: null,
},
};
},
}).configure({ inline: true });

View File

@@ -11,7 +11,7 @@ import { TableCell } from "src/ui/extensions/table/table-cell/table-cell";
import { TableHeader } from "src/ui/extensions/table/table-header/table-header";
import { TableRow } from "src/ui/extensions/table/table-row/table-row";
import { ImageExtension } from "src/ui/extensions/image";
import { ImageExtension } from "src/ui/extensions/image/image";
import { isValidHttpUrl } from "src/lib/utils";
import { Mentions } from "src/ui/mentions";
@@ -34,7 +34,8 @@ export const CoreEditorExtensions = (
},
deleteFile: DeleteImage,
restoreFile: RestoreImage,
cancelUploadImage?: () => any
cancelUploadImage?: () => any,
getAsset?: any
) => [
StarterKit.configure({
bulletList: {
@@ -79,7 +80,7 @@ export const CoreEditorExtensions = (
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({
ImageExtension(deleteFile, restoreFile, cancelUploadImage, getAsset).configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",
},

View File

@@ -1,4 +1,6 @@
import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
import { Editor, Range } from "@tiptap/core";
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
import { UploadImage } from "src/types/upload-image";
@@ -78,7 +80,8 @@ export async function startImageUpload(
view: EditorView,
pos: number,
uploadFile: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void,
editor?: Editor
) {
if (!file) {
alert("No file selected. Please select a file to upload.");
@@ -123,17 +126,15 @@ export async function startImageUpload(
setIsSubmitting?.("submitting");
try {
const src = await UploadImageHandler(file, uploadFile);
const { schema } = view.state;
pos = findPlaceholder(view.state, id);
const assetId = await UploadImageHandler(file, uploadFile);
const attrs = {
src: "",
alt: "",
assetId,
};
if (pos == null) return;
const imageSrc = typeof src === "object" ? reader.result : src;
const node = schema.nodes.image.create({ src: imageSrc });
const transaction = view.state.tr.insert(pos - 1, node).setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction);
editor?.chain().focus().insertContent({ type: "image", attrs }).run();
removePlaceholder(view, id);
} catch (error) {
console.error("Upload error: ", error);
removePlaceholder(view, id);
@@ -144,13 +145,8 @@ const UploadImageHandler = (file: File, uploadFile: UploadImage): Promise<string
try {
return new Promise(async (resolve, reject) => {
try {
const imageUrl = await uploadFile(file);
const image = new Image();
image.src = imageUrl;
image.onload = () => {
resolve(imageUrl);
};
const blob = await uploadFile(file);
resolve(blob);
} catch (error) {
if (error instanceof Error) {
console.log(error.message);

View File

@@ -26,6 +26,7 @@ export type IRichTextEditor = {
id: string;
description_html: string;
};
getAsset: any;
customClassName?: string;
editorContentCustomClassNames?: string;
onChange?: (json: any, html: string) => void;
@@ -55,6 +56,7 @@ const RichTextEditor = ({
editorContentCustomClassNames,
value,
uploadFile,
getAsset,
deleteFile,
noBorder,
cancelUploadImage,
@@ -76,6 +78,7 @@ const RichTextEditor = ({
const editor = useEditor({
onChange,
getAsset,
debouncedUpdatesEnabled,
setIsSubmitting,
setShouldShowAlert,

View File

@@ -174,9 +174,10 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
!disabled ? (
<RichTextEditor
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
uploadFile={fileService.getUploadFileFunction(workspaceSlug, projectId, issueId)}
deleteFile={fileService.getDeleteImageFunction(workspaceId)}
restoreFile={fileService.getRestoreImageFunction(workspaceId)}
getAsset={fileService.getAttachAssetToIssueFile(workspaceSlug, projectId, issueId)}
value={localIssueDescription.description_html}
rerenderOnPropsChange={localIssueDescription}
setShouldShowAlert={setShowAlert}

View File

@@ -39,7 +39,31 @@ export class FileService extends APIService {
this.cancelUpload = this.cancelUpload.bind(this);
}
async uploadFile(workspaceSlug: string, file: FormData): Promise<any> {
async attachAssetToIssue(workspaceSlug: string, projectId: string, issueId: string, assetId: string): Promise<any> {
const attachUrl = `/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/attachments/${assetId}`;
return this.get(attachUrl, {
responseType: "blob",
headers: this.getHeaders(),
})
.then(async (response) => response?.data)
.catch((error) => {
console.log(error);
throw error?.response?.data;
});
}
getAttachAssetToIssueFile(workspaceSlug: string, projectId: string, issueId: string) {
return async (assetId: string) => {
try {
const data = await this.attachAssetToIssue(workspaceSlug, projectId, issueId, assetId);
return data as Blob;
} catch (e) {
console.error(e);
}
};
}
async uploadFile(workspaceSlug: string, projectId: string, issueId: string, file: FormData): Promise<any> {
this.cancelSource = axios.CancelToken.source();
return this.post(`/api/workspaces/${workspaceSlug}/file-assets/`, file, {
headers: {
@@ -48,7 +72,7 @@ export class FileService extends APIService {
},
cancelToken: this.cancelSource.token,
})
.then((response) => response?.data)
.then(async (response) => response?.data.asset)
.catch((error) => {
if (axios.isCancel(error)) {
console.log(error.message);
@@ -63,15 +87,15 @@ export class FileService extends APIService {
this.cancelSource.cancel("Upload cancelled");
}
getUploadFileFunction(workspaceSlug: string): (file: File) => Promise<string> {
getUploadFileFunction(workspaceSlug: string, projectId: string, issueId: string): (file: File) => Promise<string> {
return async (file: File) => {
try {
const formData = new FormData();
formData.append("asset", file);
formData.append("attributes", JSON.stringify({}));
const data = await this.uploadFile(workspaceSlug, formData);
return data.asset;
const data = await this.uploadFile(workspaceSlug, projectId, issueId, formData);
return data;
} catch (e) {
console.error(e);
}