Compare commits

...

28 Commits

Author SHA1 Message Date
pablohashescobar
08ec379e9c dev: update migration to only use file asset name 2024-04-30 20:44:21 +05:30
pablohashescobar
d560d1f7df Merge branch 'develop' of github.com:makeplane/plane into update-file-uploads 2024-04-02 12:51:14 +05:30
pablohashescobar
6a6d0d881c dev: fix imports 2024-03-27 15:57:05 +05:30
pablohashescobar
6cc29dca52 Merge branch 'develop' of github.com:makeplane/plane into update-file-uploads 2024-03-27 15:45:23 +05:30
pablohashescobar
14c6cc1420 Merge branch 'update-file-uploads' of github.com:makeplane/plane into update-file-uploads 2024-03-21 17:24:04 +05:30
pablohashescobar
9bc144649a dev: fix conflicts and errors 2024-03-21 17:21:44 +05:30
pablohashescobar
6037321572 dev: fix initial set of errora 2024-03-21 14:51:08 +05:30
pablohashescobar
4165111ce1 dev: remove unused imports 2024-03-21 14:10:22 +05:30
pablohashescobar
4e88854d46 Merge branch 'develop' of github.com:makeplane/plane into update-file-uploads 2024-03-21 14:08:41 +05:30
NarayanBavisetti
6719738a9f chore: issue attachment and description endpoint for space 2024-02-13 15:23:24 +05:30
pablohashescobar
79685b33d6 dev: fix migrations and update nginx migration 2024-02-12 13:19:26 +05:30
pablohashescobar
0f0e86fdb4 dev: update migrations 2024-02-12 12:35:11 +05:30
pablohashescobar
9e310994cd dev: add cycle snapshot migration file 2024-02-12 12:33:08 +05:30
pablohashescobar
c25ac3b3a6 Merge branch 'develop' of github.com:makeplane/plane into update-file-uploads 2024-02-12 11:37:47 +05:30
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
67 changed files with 2339 additions and 749 deletions

View File

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

View File

@@ -13,7 +13,6 @@ from plane.db.models import (
Issue,
IssueActivity,
IssueAssignee,
IssueAttachment,
IssueComment,
IssueLabel,
IssueLink,
@@ -79,7 +78,7 @@ class IssueSerializer(BaseSerializer):
parsed_str = html.tostring(parsed, encoding="unicode")
data["description_html"] = parsed_str
except Exception as e:
except Exception:
raise serializers.ValidationError("Invalid HTML passed")
# Validate assignees are from project
@@ -323,22 +322,6 @@ class IssueLinkSerializer(BaseSerializer):
return super().update(instance, 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)
@@ -366,7 +349,7 @@ class IssueCommentSerializer(BaseSerializer):
parsed_str = html.tostring(parsed, encoding="unicode")
data["comment_html"] = parsed_str
except Exception as e:
except Exception:
raise serializers.ValidationError("Invalid HTML passed")
return data

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 (
@@ -580,8 +580,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,9 +1,8 @@
# Python imports
import json
from django.core.serializers.json import DjangoJSONEncoder
# Django imports
from django.core.serializers.json import DjangoJSONEncoder
from django.db import IntegrityError
from django.db.models import (
Case,
@@ -23,7 +22,6 @@ from django.utils import timezone
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.api.serializers import (
IssueActivitySerializer,
IssueCommentSerializer,
@@ -38,9 +36,9 @@ from plane.app.permissions import (
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
FileAsset,
Issue,
IssueActivity,
IssueAttachment,
IssueComment,
IssueLink,
Label,
@@ -48,6 +46,7 @@ from plane.db.models import (
ProjectMember,
)
# Module imports
from .base import BaseAPIView, WebhookMixin
@@ -128,8 +127,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,
@@ -332,8 +332,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,
@@ -70,7 +69,6 @@ from .issue import (
IssuePublicSerializer,
IssueDetailSerializer,
IssueReactionLiteSerializer,
IssueAttachmentLiteSerializer,
IssueLinkLiteSerializer,
)

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

@@ -51,19 +51,18 @@ class DynamicBaseSerializer(BaseSerializer):
for field in allowed:
if field not in self.fields:
from . import (
WorkspaceLiteSerializer,
ProjectLiteSerializer,
UserLiteSerializer,
StateLiteSerializer,
CycleIssueSerializer,
InboxIssueLiteSerializer,
IssueLinkLiteSerializer,
IssueLiteSerializer,
IssueReactionLiteSerializer,
IssueRelationSerializer,
IssueSerializer,
LabelSerializer,
CycleIssueSerializer,
IssueLiteSerializer,
IssueRelationSerializer,
InboxIssueLiteSerializer,
IssueReactionLiteSerializer,
IssueAttachmentLiteSerializer,
IssueLinkLiteSerializer,
ProjectLiteSerializer,
StateLiteSerializer,
UserLiteSerializer,
WorkspaceLiteSerializer,
)
# Expansion mapper
@@ -86,7 +85,6 @@ class DynamicBaseSerializer(BaseSerializer):
"issue_relation": IssueRelationSerializer,
"issue_inbox": InboxIssueLiteSerializer,
"issue_reactions": IssueReactionLiteSerializer,
"issue_attachment": IssueAttachmentLiteSerializer,
"issue_link": IssueLinkLiteSerializer,
"sub_issues": IssueLiteSerializer,
}
@@ -122,19 +120,18 @@ class DynamicBaseSerializer(BaseSerializer):
if expand in self.fields:
# Import all the expandable serializers
from . import (
WorkspaceLiteSerializer,
ProjectLiteSerializer,
UserLiteSerializer,
StateLiteSerializer,
IssueSerializer,
LabelSerializer,
CycleIssueSerializer,
IssueRelationSerializer,
InboxIssueLiteSerializer,
IssueLinkLiteSerializer,
IssueLiteSerializer,
IssueReactionLiteSerializer,
IssueAttachmentLiteSerializer,
IssueLinkLiteSerializer,
IssueRelationSerializer,
IssueSerializer,
LabelSerializer,
ProjectLiteSerializer,
StateLiteSerializer,
UserLiteSerializer,
WorkspaceLiteSerializer,
)
# Expansion mapper
@@ -157,7 +154,6 @@ class DynamicBaseSerializer(BaseSerializer):
"issue_relation": IssueRelationSerializer,
"issue_inbox": InboxIssueLiteSerializer,
"issue_reactions": IssueReactionLiteSerializer,
"issue_attachment": IssueAttachmentLiteSerializer,
"issue_link": IssueLinkLiteSerializer,
"sub_issues": IssueLiteSerializer,
}
@@ -179,3 +175,20 @@ 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

@@ -1,40 +1,40 @@
# Django imports
from django.utils import timezone
from django.core.validators import URLValidator
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator
from django.utils import timezone
# Third Party imports
from rest_framework import serializers
# Module imports
from .base import BaseSerializer, DynamicBaseSerializer
from .user import UserLiteSerializer
from .state import StateLiteSerializer
from .project import ProjectLiteSerializer
from .workspace import WorkspaceLiteSerializer
from plane.db.models import (
User,
CommentReaction,
Cycle,
CycleIssue,
Issue,
IssueActivity,
IssueComment,
IssueProperty,
IssueAssignee,
IssueSubscriber,
IssueComment,
IssueLabel,
IssueLink,
IssueProperty,
IssueReaction,
IssueRelation,
IssueSubscriber,
IssueVote,
Label,
CycleIssue,
Cycle,
Module,
ModuleIssue,
IssueLink,
IssueAttachment,
IssueReaction,
CommentReaction,
IssueVote,
IssueRelation,
State,
User,
)
# Module imports
from .base import BaseSerializer, DynamicBaseSerializer
from .project import ProjectLiteSerializer
from .state import StateLiteSerializer
from .user import UserLiteSerializer
from .workspace import WorkspaceLiteSerializer
class IssueFlatSerializer(BaseSerializer):
## Contain only flat fields
@@ -442,7 +442,7 @@ class IssueLinkSerializer(BaseSerializer):
raise serializers.ValidationError("Invalid URL format.")
# Check URL scheme
if not value.startswith(('http://', 'https://')):
if not value.startswith(("http://", "https://")):
raise serializers.ValidationError("Invalid URL scheme.")
return value
@@ -485,35 +485,6 @@ class IssueLinkLiteSerializer(BaseSerializer):
read_only_fields = fields
class IssueAttachmentSerializer(BaseSerializer):
class Meta:
model = IssueAttachment
fields = "__all__"
read_only_fields = [
"created_by",
"updated_by",
"created_at",
"updated_at",
"workspace",
"project",
"issue",
]
class IssueAttachmentLiteSerializer(DynamicBaseSerializer):
class Meta:
model = IssueAttachment
fields = [
"id",
"asset",
"attributes",
"issue_id",
"updated_at",
"updated_by_id",
]
read_only_fields = fields
class IssueReactionSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")

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

@@ -3,15 +3,15 @@ from django.urls import path
from plane.app.views import (
BulkCreateIssueLabelsEndpoint,
BulkDeleteIssuesEndpoint,
SubIssuesEndpoint,
IssueLinkViewSet,
IssueAttachmentEndpoint,
CommentAssetEndpoint,
CommentReactionViewSet,
ExportIssuesEndpoint,
IssueActivityEndpoint,
IssueArchiveViewSet,
IssueAttachmentEndpoint,
IssueCommentViewSet,
IssueDraftViewSet,
IssueLinkViewSet,
IssueListEndpoint,
IssueReactionViewSet,
IssueRelationViewSet,
@@ -19,6 +19,7 @@ from plane.app.views import (
IssueUserDisplayPropertyEndpoint,
IssueViewSet,
LabelViewSet,
SubIssuesEndpoint,
)
urlpatterns = [
@@ -109,16 +110,6 @@ urlpatterns = [
),
name="project-issue-links",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/",
IssueAttachmentEndpoint.as_view(),
name="project-issue-attachments",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/<uuid:pk>/",
IssueAttachmentEndpoint.as_view(),
name="project-issue-attachments",
),
path(
"workspaces/<str:slug>/export-issues/",
ExportIssuesEndpoint.as_view(),
@@ -297,5 +288,26 @@ 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
path(
"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>/attachments/<uuid:workspace_id>/<str:asset_key>/",
IssueAttachmentEndpoint.as_view(),
name="project-issue-attachments",
),
]

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

@@ -1,23 +1,23 @@
from django.urls import path
from plane.app.views import (
ProjectViewSet,
ProjectInvitationsViewset,
ProjectMemberViewSet,
ProjectMemberUserEndpoint,
ProjectJoinEndpoint,
AddTeamToProjectEndpoint,
ProjectUserViewsEndpoint,
ProjectIdentifierEndpoint,
ProjectFavoritesViewSet,
UserProjectInvitationsViewset,
ProjectPublicCoverImagesEndpoint,
ProjectDeployBoardViewSet,
UserProjectRolesEndpoint,
ProjectArchiveUnarchiveEndpoint,
ProjectCoverImageEndpoint,
ProjectDeployBoardViewSet,
ProjectFavoritesViewSet,
ProjectIdentifierEndpoint,
ProjectInvitationsViewset,
ProjectJoinEndpoint,
ProjectMemberUserEndpoint,
ProjectMemberViewSet,
ProjectPublicCoverImagesEndpoint,
ProjectUserViewsEndpoint,
ProjectViewSet,
UserProjectInvitationsViewset,
UserProjectRolesEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/projects/",
@@ -181,4 +181,14 @@ urlpatterns = [
ProjectArchiveUnarchiveEndpoint.as_view(),
name="project-archive-unarchive",
),
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

@@ -1,33 +1,32 @@
from django.urls import path
from plane.app.views import (
UserWorkspaceInvitationsViewSet,
WorkSpaceViewSet,
WorkspaceJoinEndpoint,
WorkSpaceMemberViewSet,
WorkspaceInvitationsViewset,
WorkspaceMemberUserEndpoint,
WorkspaceMemberUserViewsEndpoint,
WorkSpaceAvailabilityCheckEndpoint,
ExportWorkspaceUserActivityEndpoint,
TeamMemberViewSet,
UserLastProjectWithWorkspaceEndpoint,
UserWorkspaceInvitationsViewSet,
WorkSpaceAvailabilityCheckEndpoint,
WorkspaceCyclesEndpoint,
WorkspaceEstimatesEndpoint,
WorkspaceInvitationsViewset,
WorkspaceJoinEndpoint,
WorkspaceLabelsEndpoint,
WorkspaceLogoEndpoint,
WorkspaceMemberUserEndpoint,
WorkspaceMemberUserViewsEndpoint,
WorkSpaceMemberViewSet,
WorkspaceModulesEndpoint,
WorkspaceProjectMemberEndpoint,
WorkspaceStatesEndpoint,
WorkspaceThemeViewSet,
WorkspaceUserProfileStatsEndpoint,
WorkspaceUserActivityEndpoint,
WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint,
WorkspaceProjectMemberEndpoint,
WorkspaceUserProfileStatsEndpoint,
WorkspaceUserPropertiesEndpoint,
WorkspaceStatesEndpoint,
WorkspaceEstimatesEndpoint,
ExportWorkspaceUserActivityEndpoint,
WorkspaceModulesEndpoint,
WorkspaceCyclesEndpoint,
WorkSpaceViewSet,
)
urlpatterns = [
path(
"workspace-slug-check/",
@@ -237,4 +236,14 @@ urlpatterns = [
WorkspaceCyclesEndpoint.as_view(),
name="workspace-cycles",
),
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

@@ -6,6 +6,7 @@ from .project.base import (
ProjectPublicCoverImagesEndpoint,
ProjectDeployBoardViewSet,
ProjectArchiveUnarchiveEndpoint,
ProjectCoverImageEndpoint,
)
from .project.invite import (
@@ -26,6 +27,8 @@ from .user.base import (
UpdateUserOnBoardedEndpoint,
UpdateUserTourCompletedEndpoint,
UserActivityEndpoint,
UserAvatarEndpoint,
UserCoverImageEndpoint,
)
from .oauth import OauthEndpoint
@@ -38,7 +41,8 @@ from .workspace.base import (
WorkSpaceAvailabilityCheckEndpoint,
UserWorkspaceDashboardEndpoint,
WorkspaceThemeViewSet,
ExportWorkspaceUserActivityEndpoint
ExportWorkspaceUserActivityEndpoint,
WorkspaceLogoEndpoint,
)
from .workspace.member import (
@@ -98,7 +102,7 @@ from .cycle.issue import (
CycleIssueViewSet,
)
from .asset.base import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
from .asset.base import FileAssetEndpoint, FileAssetViewSet
from .issue.base import (
IssueListEndpoint,
IssueViewSet,
@@ -114,13 +118,12 @@ from .issue.archive import (
IssueArchiveViewSet,
)
from .issue.attachment import (
IssueAttachmentEndpoint,
)
from .issue.attachment import IssueAttachmentEndpoint
from .issue.comment import (
IssueCommentViewSet,
CommentReactionViewSet,
CommentAssetEndpoint,
)
from .issue.draft import IssueDraftViewSet
@@ -186,6 +189,7 @@ from .page.base import (
PageFavoriteViewSet,
PageLogEndpoint,
SubPagesEndpoint,
PageAssetEndpoint,
)
from .search import GlobalSearchEndpoint, IssueSearchEndpoint

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

@@ -1,43 +1,39 @@
# Python imports
import json
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.core import serializers
# Django imports
from django.db.models import (
Func,
F,
Q,
Func,
OuterRef,
Value,
Q,
UUIDField,
Value,
)
from django.core import serializers
from django.db.models.functions import Coalesce
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models.functions import Coalesce
from rest_framework import status
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import (
CycleIssueSerializer,
IssueSerializer,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import Cycle, CycleIssue, FileAsset, Issue, IssueLink
from plane.utils.issue_filters import issue_filters
# Module imports
from .. import BaseViewSet, WebhookMixin
from plane.app.serializers import (
IssueSerializer,
CycleIssueSerializer,
)
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import (
Cycle,
CycleIssue,
Issue,
IssueLink,
IssueAttachment,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
class CycleIssueViewSet(WebhookMixin, BaseViewSet):
@@ -115,16 +111,17 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=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

@@ -36,7 +36,7 @@ from plane.db.models import (
Dashboard,
Project,
IssueLink,
IssueAttachment,
FileAsset,
IssueRelation,
User,
)
@@ -123,8 +123,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"))
@@ -278,8 +279,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

@@ -1,42 +1,53 @@
# Python imports
import json
# Django import
from django.utils import timezone
from django.db.models import Q, Count, OuterRef, Func, F, Prefetch, Exists
from django.core.serializers.json import DjangoJSONEncoder
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import (
Count,
Exists,
F,
Func,
OuterRef,
Prefetch,
Q,
UUIDField,
Value,
)
from django.db.models.functions import Coalesce
# Django import
from django.utils import timezone
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from ..base import BaseViewSet
from plane.app.permissions import ProjectBasePermission, ProjectLitePermission
from plane.app.serializers import (
InboxIssueSerializer,
InboxSerializer,
IssueCreateSerializer,
IssueDetailSerializer,
IssueSerializer,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
FileAsset,
Inbox,
InboxIssue,
Issue,
State,
IssueLink,
IssueAttachment,
ProjectMember,
IssueReaction,
IssueSubscriber,
)
from plane.app.serializers import (
IssueCreateSerializer,
IssueSerializer,
InboxSerializer,
InboxIssueSerializer,
IssueDetailSerializer,
ProjectMember,
State,
)
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.issue_activites_task import issue_activity
# Module imports
from ..base import BaseViewSet
class InboxViewSet(BaseViewSet):
@@ -118,8 +129,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"))
@@ -133,6 +145,15 @@ class InboxIssueViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
@@ -405,12 +426,6 @@ class InboxIssueViewSet(BaseViewSet):
),
)
)
.prefetch_related(
Prefetch(
"issue_attachment",
queryset=IssueAttachment.objects.select_related("issue"),
)
)
.prefetch_related(
Prefetch(
"issue_link",

View File

@@ -1,53 +1,55 @@
# Python imports
import json
# Django imports
from django.utils import timezone
from django.db.models import (
Prefetch,
OuterRef,
Func,
F,
Q,
Case,
Value,
CharField,
When,
Exists,
Max,
UUIDField,
)
from django.core.serializers.json import DjangoJSONEncoder
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import (
Case,
CharField,
Exists,
F,
Func,
Max,
OuterRef,
Prefetch,
Q,
UUIDField,
Value,
When,
)
from django.db.models.functions import Coalesce
# Django imports
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from rest_framework import status
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from .. import BaseViewSet
from plane.app.serializers import (
IssueSerializer,
IssueFlatSerializer,
IssueDetailSerializer,
)
from plane.app.permissions import (
ProjectEntityPermission,
)
from plane.db.models import (
Issue,
IssueLink,
IssueAttachment,
IssueSubscriber,
IssueReaction,
from plane.app.serializers import (
IssueDetailSerializer,
IssueFlatSerializer,
IssueSerializer,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
FileAsset,
Issue,
IssueLink,
IssueReaction,
IssueSubscriber,
)
from plane.utils.issue_filters import issue_filters
# Module imports
from .. import BaseViewSet
class IssueArchiveViewSet(BaseViewSet):
permission_classes = [
@@ -77,16 +79,17 @@ class IssueArchiveViewSet(BaseViewSet):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
@@ -253,12 +256,6 @@ class IssueArchiveViewSet(BaseViewSet):
),
)
)
.prefetch_related(
Prefetch(
"issue_attachment",
queryset=IssueAttachment.objects.select_related("issue"),
)
)
.prefetch_related(
Prefetch(
"issue_link",

View File

@@ -2,34 +2,43 @@
import json
# Django imports
from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
from django.http import HttpResponseRedirect
from django.utils import timezone
# Third Party imports
from rest_framework.response import Response
# Third party imports
from rest_framework import status
from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.response import Response
# Module imports
from .. import BaseAPIView
from plane.app.serializers import IssueAttachmentSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import IssueAttachment
from plane.app.serializers import FileAssetSerializer
from plane.app.views.base import BaseAPIView
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import FileAsset, Workspace
from plane.utils.presigned_url_generator import generate_download_presigned_url
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,
@@ -47,10 +56,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,
@@ -62,12 +80,32 @@ 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)

View File

@@ -1,57 +1,58 @@
# Python imports
import json
# Django imports
from django.utils import timezone
from django.db.models import (
Prefetch,
OuterRef,
Func,
F,
Q,
Case,
Value,
CharField,
When,
Exists,
Max,
)
from django.core.serializers.json import DjangoJSONEncoder
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import UUIDField
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import (
Case,
CharField,
Exists,
F,
Func,
Max,
OuterRef,
Prefetch,
Q,
UUIDField,
Value,
When,
)
from django.db.models.functions import Coalesce
# Django imports
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from rest_framework import status
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from .. import BaseViewSet, BaseAPIView, WebhookMixin
from plane.app.serializers import (
IssuePropertySerializer,
IssueSerializer,
IssueCreateSerializer,
IssueDetailSerializer,
)
from plane.app.permissions import (
ProjectEntityPermission,
ProjectLitePermission,
)
from plane.db.models import (
Project,
Issue,
IssueProperty,
IssueLink,
IssueAttachment,
IssueSubscriber,
IssueReaction,
from plane.app.serializers import (
IssueCreateSerializer,
IssueDetailSerializer,
IssuePropertySerializer,
IssueSerializer,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Issue,
IssueLink,
IssueProperty,
IssueReaction,
IssueSubscriber,
Project,
)
from plane.utils.issue_filters import issue_filters
# Module imports
from .. import BaseAPIView, BaseViewSet, WebhookMixin
class IssueListEndpoint(BaseAPIView):
@@ -86,14 +87,6 @@ class IssueListEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
@@ -281,14 +274,6 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
@@ -516,12 +501,6 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
),
)
)
.prefetch_related(
Prefetch(
"issue_attachment",
queryset=IssueAttachment.objects.select_related("issue"),
)
)
.prefetch_related(
Prefetch(
"issue_link",

View File

@@ -2,27 +2,33 @@
import json
# Django imports
from django.utils import timezone
from django.db.models import Exists
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import Exists
from django.http import HttpResponseRedirect
from django.utils import timezone
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.response import Response
# Module imports
from .. import BaseViewSet, WebhookMixin
from plane.app.serializers import (
IssueCommentSerializer,
CommentReactionSerializer,
)
from plane.app.permissions import ProjectLitePermission
from plane.app.serializers import (
CommentReactionSerializer,
FileAssetSerializer,
IssueCommentSerializer,
)
from plane.app.views.base import BaseAPIView, BaseViewSet, WebhookMixin
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
CommentReaction,
FileAsset,
IssueComment,
ProjectMember,
CommentReaction,
Workspace,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.presigned_url_generator import generate_download_presigned_url
class IssueCommentViewSet(WebhookMixin, BaseViewSet):
@@ -219,3 +225,70 @@ class CommentReactionViewSet(BaseViewSet):
)
comment_reaction.delete()
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

@@ -38,8 +38,8 @@ from plane.app.serializers import (
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
FileAsset,
Issue,
IssueAttachment,
IssueLink,
IssueReaction,
IssueSubscriber,
@@ -73,16 +73,17 @@ class IssueDraftViewSet(BaseViewSet):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
@@ -314,12 +315,6 @@ class IssueDraftViewSet(BaseViewSet):
),
)
)
.prefetch_related(
Prefetch(
"issue_attachment",
queryset=IssueAttachment.objects.select_related("issue"),
)
)
.prefetch_related(
Prefetch(
"issue_link",

View File

@@ -1,37 +1,38 @@
# Python imports
import json
from collections import defaultdict
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import (
F,
Func,
OuterRef,
Q,
UUIDField,
Value,
)
from django.db.models.functions import Coalesce
# Django imports
from django.utils import timezone
from django.db.models import (
OuterRef,
Func,
F,
Q,
Value,
UUIDField,
)
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models.functions import Coalesce
from rest_framework import status
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from .. import BaseAPIView
from plane.app.serializers import IssueSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import IssueSerializer
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Issue,
IssueLink,
IssueAttachment,
)
from plane.bgtasks.issue_activites_task import issue_activity
from collections import defaultdict
# Module imports
from .. import BaseAPIView
class SubIssuesEndpoint(BaseAPIView):
@@ -54,14 +55,6 @@ class SubIssuesEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")

View File

@@ -1,36 +1,37 @@
# Python imports
import json
# Django Imports
from django.utils import timezone
from django.db.models import F, OuterRef, Func, Q
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
from django.db.models import F, Func, OuterRef, Q, UUIDField, Value
from django.db.models.functions import Coalesce
# Django Imports
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from rest_framework import status
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import (
IssueSerializer,
ModuleIssueSerializer,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
FileAsset,
Issue,
IssueLink,
ModuleIssue,
Project,
)
from plane.utils.issue_filters import issue_filters
# Module imports
from .. import BaseViewSet, WebhookMixin
from plane.app.serializers import (
ModuleIssueSerializer,
IssueSerializer,
)
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import (
ModuleIssue,
Project,
Issue,
IssueLink,
IssueAttachment,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
@@ -65,16 +66,17 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=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

@@ -4,26 +4,33 @@ from datetime import datetime
# Django imports
from django.db import connection
from django.db.models import Exists, OuterRef, Q
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.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.response import Response
# Module imports
from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import (
FileAssetSerializer,
PageFavoriteSerializer,
PageLogSerializer,
PageSerializer,
SubPageSerializer,
)
from plane.db.models import (
FileAsset,
Page,
PageFavorite,
PageLog,
ProjectMember,
Workspace,
)
from plane.utils.presigned_url_generator import generate_download_presigned_url
# Module imports
from ..base import BaseAPIView, BaseViewSet
@@ -359,3 +366,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

@@ -2,54 +2,57 @@
import boto3
# Django imports
from django.conf import settings
from django.db import IntegrityError
from django.db.models import (
Prefetch,
Q,
Exists,
OuterRef,
F,
Func,
OuterRef,
Prefetch,
Q,
Subquery,
)
from django.conf import settings
from django.http import HttpResponseRedirect
from django.utils import timezone
# Third Party imports
from rest_framework import serializers, status
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from rest_framework import serializers
from rest_framework.permissions import AllowAny
# Module imports
from plane.app.views.base import BaseViewSet, BaseAPIView, WebhookMixin
from plane.app.serializers import (
ProjectSerializer,
ProjectListSerializer,
ProjectFavoriteSerializer,
ProjectDeployBoardSerializer,
)
from plane.app.permissions import (
ProjectBasePermission,
ProjectMemberPermission,
)
from plane.app.serializers import (
FileAssetSerializer,
ProjectDeployBoardSerializer,
ProjectFavoriteSerializer,
ProjectListSerializer,
ProjectLiteSerializer,
ProjectSerializer,
)
from plane.app.views.base import BaseAPIView, BaseViewSet, WebhookMixin
from plane.db.models import (
Cycle,
FileAsset,
Inbox,
Issue,
IssueProperty,
Module,
Project,
ProjectMember,
Workspace,
State,
ProjectDeployBoard,
ProjectFavorite,
ProjectIdentifier,
Module,
Cycle,
Inbox,
ProjectDeployBoard,
IssueProperty,
Issue,
ProjectMember,
State,
Workspace,
)
from plane.utils.cache import cache_response
from plane.utils.presigned_url_generator import generate_download_presigned_url
class ProjectViewSet(WebhookMixin, BaseViewSet):
@@ -650,3 +653,51 @@ class ProjectDeployBoardViewSet(BaseViewSet):
serializer = ProjectDeployBoardSerializer(project_deploy_board)
return Response(serializer.data, 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,22 +1,33 @@
# Django imports
from django.db.models import Case, Count, IntegerField, Q, When
from django.http import HttpResponseRedirect
# Third party imports
from rest_framework import status
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
# Module imports
from plane.app.serializers import (
FileAssetSerializer,
IssueActivitySerializer,
UserMeSerializer,
UserMeSettingsSerializer,
UserSerializer,
)
from plane.app.views.base import BaseAPIView, BaseViewSet
from plane.db.models import IssueActivity, ProjectMember, User, WorkspaceMember
from plane.db.models import (
FileAsset,
IssueActivity,
ProjectMember,
User,
WorkspaceMember,
)
from plane.license.models import Instance, InstanceAdmin
from plane.utils.cache import cache_response, invalidate_cache
from plane.utils.paginator import BasePaginator
from plane.utils.presigned_url_generator import generate_download_presigned_url
class UserEndpoint(BaseViewSet):
@@ -189,3 +200,97 @@ 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

@@ -1,47 +1,48 @@
# Django imports
from django.db.models import (
Q,
OuterRef,
Func,
F,
Case,
Value,
CharField,
When,
Exists,
Max,
)
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import UUIDField
from django.db.models import (
Case,
CharField,
Exists,
F,
Func,
Max,
OuterRef,
Q,
UUIDField,
Value,
When,
)
from django.db.models.functions import Coalesce
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from rest_framework import status
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from plane.app.permissions import (
ProjectEntityPermission,
WorkspaceEntityPermission,
)
from plane.app.serializers import (
IssueSerializer,
IssueViewFavoriteSerializer,
IssueViewSerializer,
)
from plane.db.models import (
FileAsset,
Issue,
IssueLink,
IssueView,
IssueViewFavorite,
Workspace,
)
from plane.utils.issue_filters import issue_filters
# Module imports
from .. import BaseViewSet
from plane.app.serializers import (
IssueViewSerializer,
IssueSerializer,
IssueViewFavoriteSerializer,
)
from plane.app.permissions import (
WorkspaceEntityPermission,
ProjectEntityPermission,
)
from plane.db.models import (
Workspace,
IssueView,
Issue,
IssueViewFavorite,
IssueLink,
IssueAttachment,
)
from plane.utils.issue_filters import issue_filters
class GlobalViewViewSet(BaseViewSet):
@@ -97,8 +98,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

@@ -4,6 +4,8 @@ import io
from datetime import date
from dateutil.relativedelta import relativedelta
# Django imports
from django.db import IntegrityError
from django.db.models import (
Count,
@@ -15,28 +17,29 @@ from django.db.models import (
)
from django.db.models.fields import DateField
from django.db.models.functions import Cast, ExtractDay, ExtractWeek
# Django imports
from django.http import HttpResponse
from django.http import HttpResponse, HttpResponseRedirect
from django.utils import timezone
# Third party modules
from rest_framework import status
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
# Module imports
from plane.app.permissions import (
WorkSpaceAdminPermission,
WorkSpaceBasePermission,
WorkspaceEntityPermission,
)
# Module imports
from plane.app.serializers import (
FileAssetSerializer,
WorkSpaceSerializer,
WorkspaceThemeSerializer,
)
from plane.app.views.base import BaseAPIView, BaseViewSet
from plane.db.models import (
FileAsset,
Issue,
IssueActivity,
Workspace,
@@ -44,6 +47,7 @@ from plane.db.models import (
WorkspaceTheme,
)
from plane.utils.cache import cache_response, invalidate_cache
from plane.utils.presigned_url_generator import generate_download_presigned_url
class WorkSpaceViewSet(BaseViewSet):
@@ -416,3 +420,52 @@ class ExportWorkspaceUserActivityEndpoint(BaseAPIView):
'attachment; filename="workspace-user-activity.csv"'
)
return response
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

@@ -1,60 +1,61 @@
# Python imports
from datetime import date
from dateutil.relativedelta import relativedelta
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import (
Case,
CharField,
Count,
F,
Func,
IntegerField,
Max,
OuterRef,
Q,
UUIDField,
Value,
When,
)
from django.db.models.fields import DateField
from django.db.models.functions import Cast, Coalesce, ExtractWeek
# Django imports
from django.utils import timezone
from django.db.models import (
OuterRef,
Func,
F,
Q,
Count,
Case,
Value,
CharField,
When,
Max,
IntegerField,
UUIDField,
)
from django.db.models.functions import ExtractWeek, Cast
from django.db.models.fields import DateField
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models.functions import Coalesce
# Third party modules
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import (
WorkspaceEntityPermission,
WorkspaceViewerPermission,
)
# Module imports
from plane.app.serializers import (
WorkSpaceSerializer,
ProjectMemberSerializer,
IssueActivitySerializer,
IssueSerializer,
ProjectMemberSerializer,
WorkSpaceSerializer,
WorkspaceUserPropertiesSerializer,
)
from plane.app.views.base import BaseAPIView
from plane.db.models import (
User,
Workspace,
ProjectMember,
IssueActivity,
CycleIssue,
FileAsset,
Issue,
IssueActivity,
IssueLink,
IssueAttachment,
IssueSubscriber,
Project,
ProjectMember,
User,
Workspace,
WorkspaceMember,
CycleIssue,
WorkspaceUserProperties,
)
from plane.app.permissions import (
WorkspaceEntityPermission,
WorkspaceViewerPermission,
)
from plane.utils.issue_filters import issue_filters
@@ -137,16 +138,17 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=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,13 +1,13 @@
# Python imports
from datetime import timedelta
# Django imports
from django.utils import timezone
from django.db.models import Q
# Third party imports
from celery import shared_task
# Django imports
from django.db.models import Q
from django.utils import timezone
# Module imports
from plane.db.models import FileAsset
@@ -26,3 +26,14 @@ def delete_file_asset():
file_asset.asset.delete(save=False)
# Delete the file object
file_asset.delete()
@shared_task
def file_asset_size():
asset_size = []
for asset in FileAsset.objects.filter(size__isnull=True):
asset.size = asset.asset.size
asset_size.append(asset)
FileAsset.objects.bulk_update(asset_size, ["size"], batch_size=50)
print("File asset size updated successfully")

View File

@@ -1,43 +1,26 @@
# Python imports
import boto3
import json
from botocore.exceptions import ClientError
from django.conf import settings
# Django imports
from django.core.management import BaseCommand
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}"
)
self.style.ERROR(f"Error removing public access policy: {e}")
)
def handle(self, *args, **options):
@@ -58,7 +41,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 +59,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,21 @@
# Python imports
# Django imports
from django.core.management import BaseCommand
# Module imports
class Command(BaseCommand):
help = "Check the file asset size of the file"
def handle(self, *args, **options):
from plane.bgtasks.file_asset_task import file_asset_size
# Start the queueing
file_asset_size.delay()
self.stdout.write(
self.style.SUCCESS("File asset size pushed to queue")
)

View File

@@ -0,0 +1,238 @@
# Generated by Django 4.2.10 on 2024-03-21 09:13
import django.db.models
from django.conf import settings
from django.db import migrations, 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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", "0062_cycle_archived_at_module_archived_at_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,228 @@
# Generated by Django 4.2.10 on 2024-03-21 09:15
# Third party imports
from bs4 import BeautifulSoup
from django.conf import settings
from django.db import migrations
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"] = 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"] = 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"] = 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"] = 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"] = 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"] = 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", "0063_auto_20240321_0913"),
]
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,43 @@
# Generated by Django 4.2.10 on 2024-03-21 09:17
from django.db import migrations
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", "0064_auto_20240321_0915"),
]
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

@@ -2,18 +2,20 @@
from uuid import uuid4
# Django import
from django.db import models
from django.core.exceptions import ValidationError
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
# Module import
from . import BaseModel
from plane.settings.storage import S3PrivateBucketStorage
from .base import BaseModel
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 +34,7 @@ class FileAsset(BaseModel):
validators=[
file_size,
],
storage=S3PrivateBucketStorage(),
)
workspace = models.ForeignKey(
"db.Workspace",
@@ -39,7 +42,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 +69,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

@@ -1,20 +1,20 @@
# Python import
from uuid import uuid4
# Python imports
import uuid
# Django imports
from django.contrib.postgres.fields import ArrayField
from django.db import models
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.core.validators import MinValueValidator, MaxValueValidator
from django.core.exceptions import ValidationError
from django.utils import timezone
# Module imports
from . import ProjectBaseModel
from plane.utils.html_processor import strip_tags
from .project import ProjectBaseModel
def get_default_properties():
return {
@@ -96,6 +96,17 @@ class IssueManager(models.Manager):
)
def get_upload_path(instance, filename):
if instance.workspace_id is not None:
return f"{instance.workspace.id}/{uuid.uuid4().hex}"
return f"user-{uuid.uuid4().hex}"
def file_size(value):
if value.size > settings.FILE_SIZE_LIMIT:
raise ValidationError("File too large. Size should not exceed 5 MB.")
class Issue(ProjectBaseModel):
PRIORITY_CHOICES = (
("urgent", "Urgent"),
@@ -337,38 +348,6 @@ 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):
# File limit check is only for cloud hosted
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,
@@ -390,9 +369,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,
@@ -424,9 +400,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

@@ -4,13 +4,13 @@ import string
import uuid
import pytz
# Django imports
from django.contrib.auth.models import (
AbstractBaseUser,
PermissionsMixin,
UserManager,
)
# Django imports
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
@@ -44,7 +44,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

@@ -228,14 +228,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

@@ -3,3 +3,5 @@ from .user import UserLiteSerializer
from .issue import LabelLiteSerializer, StateLiteSerializer
from .state import StateSerializer, StateLiteSerializer
from .asset import FileAssetSerializer

View File

@@ -0,0 +1,15 @@
from .base import BaseFileSerializer
from plane.db.models import FileAsset
class FileAssetSerializer(BaseFileSerializer):
class Meta:
model = FileAsset
fields = "__all__"
read_only_fields = [
"created_by",
"updated_by",
"created_at",
"updated_at",
]

View File

@@ -56,3 +56,22 @@ class DynamicBaseSerializer(BaseSerializer):
self.fields.pop(field_name)
return self.fields
class BaseFileSerializer(DynamicBaseSerializer):
class Meta:
abstract = True # Make this serializer abstract
def to_representation(self, instance):
"""
Object instance -> Dict of primitive datatypes.
"""
response = super().to_representation(instance)
response[
"asset"
] = (
instance.asset.name
) # Ensure 'asset' field is consistently serialized
# Apply custom method to get download URL
return response

View File

@@ -22,7 +22,6 @@ from plane.db.models import (
CycleIssue,
ModuleIssue,
IssueLink,
IssueAttachment,
IssueReaction,
CommentReaction,
IssueVote,
@@ -171,22 +170,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 +201,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

@@ -1,11 +1,12 @@
from django.urls import path
from plane.space.views import (
IssueRetrievePublicEndpoint,
CommentAssetPublicEndpoint,
CommentReactionPublicViewSet,
IssueAttachmentPublicEndpoint,
IssueCommentPublicViewSet,
IssueReactionPublicViewSet,
CommentReactionPublicViewSet,
IssueRetrievePublicEndpoint,
)
urlpatterns = [
@@ -35,6 +36,26 @@ urlpatterns = [
),
name="issue-comments-project-board",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/",
IssueAttachmentPublicEndpoint.as_view(),
name="project-issue-attachments",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/<uuid:workspace_id>/<str:asset_key>/",
IssueAttachmentPublicEndpoint.as_view(),
name="project-issue-attachments",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/comments/<uuid:comment_id>/attachments/",
CommentAssetPublicEndpoint.as_view(),
name="issue-comments-project-board-attachments",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/comments/<uuid:comment_id>/attachments/<uuid:workspace_id>/<str:asset_key>/",
CommentAssetPublicEndpoint.as_view(),
name="issue-comments-project-board-attachments",
),
path(
"workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/reactions/",
IssueReactionPublicViewSet.as_view(
@@ -73,4 +94,24 @@ urlpatterns = [
),
name="comment-reactions-project-board",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/",
IssueAttachmentPublicEndpoint.as_view(),
name="project-issue-attachments",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/<uuid:workspace_id>/<str:asset_key>/",
IssueAttachmentPublicEndpoint.as_view(),
name="project-issue-attachments",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/comments/<uuid:comment_id>/attachments/",
CommentAssetPublicEndpoint.as_view(),
name="issue-comments-project-board-attachments",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/comments/<uuid:comment_id>/attachments/<uuid:workspace_id>/<str:asset_key>/",
CommentAssetPublicEndpoint.as_view(),
name="issue-comments-project-board-attachments",
),
]

View File

@@ -10,6 +10,8 @@ from .issue import (
IssueVotePublicViewSet,
IssueRetrievePublicEndpoint,
ProjectIssuesPublicEndpoint,
CommentAssetPublicEndpoint,
IssueAttachmentPublicEndpoint,
)
from .inbox import InboxIssuePublicViewSet

View File

@@ -2,32 +2,32 @@
import json
# Django import
from django.utils import timezone
from django.db.models import Q, OuterRef, Func, F, Prefetch
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import F, Func, OuterRef, Prefetch, Q
from django.utils import timezone
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .base import BaseViewSet
from plane.db.models import (
InboxIssue,
Issue,
State,
IssueLink,
IssueAttachment,
ProjectDeployBoard,
)
from plane.app.serializers import (
IssueSerializer,
InboxIssueSerializer,
IssueCreateSerializer,
IssueSerializer,
IssueStateInboxSerializer,
)
from plane.utils.issue_filters import issue_filters
from plane.app.views.base import BaseViewSet
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
FileAsset,
InboxIssue,
Issue,
IssueLink,
ProjectDeployBoard,
State,
)
from plane.utils.issue_filters import issue_filters
class InboxIssuePublicViewSet(BaseViewSet):
@@ -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

@@ -1,56 +1,61 @@
# Python imports
import json
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import (
Case,
CharField,
Exists,
F,
Func,
IntegerField,
Max,
OuterRef,
Prefetch,
Q,
Value,
When,
)
from django.http import HttpResponseRedirect
# Django imports
from django.utils import timezone
from django.db.models import (
Prefetch,
OuterRef,
Func,
F,
Q,
Case,
Value,
CharField,
When,
Exists,
Max,
IntegerField,
)
from django.core.serializers.json import DjangoJSONEncoder
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
# Module imports
from .base import BaseViewSet, BaseAPIView
from plane.app.serializers import (
IssueCommentSerializer,
IssueReactionSerializer,
CommentReactionSerializer,
IssueVoteSerializer,
FileAssetSerializer,
IssueCommentSerializer,
IssuePublicSerializer,
IssueReactionSerializer,
IssueVoteSerializer,
)
from plane.app.views.base import BaseAPIView, BaseViewSet
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
CommentReaction,
FileAsset,
Issue,
IssueComment,
Label,
IssueLink,
IssueAttachment,
State,
ProjectMember,
IssueReaction,
CommentReaction,
ProjectDeployBoard,
IssueVote,
Label,
ProjectDeployBoard,
ProjectMember,
ProjectPublicMember,
State,
Workspace,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters
from plane.utils.presigned_url_generator import generate_download_presigned_url
class IssueCommentPublicViewSet(BaseViewSet):
@@ -215,6 +220,182 @@ class IssueCommentPublicViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
class IssueAttachmentPublicEndpoint(BaseAPIView):
def get_permissions(self):
if self.action in ["get"]:
self.permission_classes = [
AllowAny,
]
else:
self.permission_classes = [
IsAuthenticated,
]
return super(IssueAttachmentPublicEndpoint, self).get_permissions()
parser_classes = (
MultiPartParser,
FormParser,
JSONParser,
)
def post(self, request, slug, project_id, issue_id):
serializer = FileAssetSerializer(data=request.data)
workspace = Workspace.objects.get(slug=slug)
if serializer.is_valid():
serializer.save(
workspace=workspace,
project_id=project_id,
entity_identifier=issue_id,
)
issue_activity.delay(
type="attachment.activity.created",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
serializer.data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(
self, request, slug, project_id, issue_id, workspace_id, asset_key
):
key = f"{workspace_id}/{asset_key}"
asset = FileAsset.objects.get(
asset=key,
entity_identifier=issue_id,
entity_type="issue_attachment",
workspace__slug=slug,
project_id=project_id,
)
asset.is_deleted = True
asset.save()
issue_activity.delay(
type="attachment.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(status=status.HTTP_204_NO_CONTENT)
def get(
self,
request,
slug,
project_id,
issue_id,
workspace_id=None,
asset_key=None,
):
if workspace_id and asset_key:
key = f"{workspace_id}/{asset_key}"
url = generate_download_presigned_url(
key=key,
host=request.get_host(),
scheme=request.scheme,
)
return HttpResponseRedirect(url)
# For listing
issue_attachments = FileAsset.objects.filter(
entity_type="issue_attachment",
entity_identifier=issue_id,
workspace__slug=slug,
project_id=project_id,
)
serializer = FileAssetSerializer(issue_attachments, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
class CommentAssetPublicEndpoint(BaseAPIView):
def get_permissions(self):
if self.action in ["get"]:
self.permission_classes = [
AllowAny,
]
else:
self.permission_classes = [
IsAuthenticated,
]
return super(CommentAssetPublicEndpoint, self).get_permissions()
parser_classes = (
MultiPartParser,
FormParser,
JSONParser,
)
def post(self, request, slug, project_id, comment_id):
serializer = FileAssetSerializer(data=request.data)
workspace = Workspace.objects.get(slug=slug)
if serializer.is_valid():
serializer.save(
workspace=workspace,
project_id=project_id,
entity_type="comment",
entity_identifier=comment_id,
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(
self, request, slug, project_id, comment_id, workspace_id, asset_key
):
key = f"{workspace_id}/{asset_key}"
asset = FileAsset.objects.get(
asset=key,
entity_identifier=comment_id,
entity_type="comment",
workspace__slug=slug,
project_id=project_id,
)
asset.is_deleted = True
asset.save()
return Response(status=status.HTTP_204_NO_CONTENT)
def get(
self,
request,
slug,
project_id,
comment_id,
workspace_id=None,
asset_key=None,
):
if workspace_id and asset_key:
key = f"{workspace_id}/{asset_key}"
url = generate_download_presigned_url(
key=key,
host=request.get_host(),
scheme=request.scheme,
)
return HttpResponseRedirect(url)
# For listing
comment_assets = FileAsset.objects.filter(
entity_type="comment",
entity_identifier=comment_id,
workspace__slug=slug,
project_id=project_id,
)
serializer = FileAssetSerializer(comment_assets, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
class IssueReactionPublicViewSet(BaseViewSet):
serializer_class = IssueReactionSerializer
model = IssueReaction
@@ -570,8 +751,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,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

@@ -24,16 +24,20 @@ http {
}
location /api/ {
proxy_set_header Host ${dollar}host;
proxy_set_header X-Real-IP ${dollar}remote_addr;
proxy_set_header X-Forwarded-For ${dollar}proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto ${dollar}scheme;
proxy_pass http://api:8000/api/;
}
location /spaces/ {
rewrite ^/spaces/?$ /spaces/login break;
proxy_pass http://space:4000/spaces/;
}
location /${BUCKET_NAME}/ {
proxy_pass http://plane-minio:9000/uploads/;
proxy_pass http://plane-minio:9000/${BUCKET_NAME}/;
}
}
}

View File

@@ -16,11 +16,20 @@ 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/;
}
location /api/ {
proxy_set_header Host ${dollar}host;
proxy_set_header X-Real-IP ${dollar}remote_addr;
proxy_set_header X-Forwarded-For ${dollar}proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto ${dollar}scheme;
proxy_pass http://api:8000/api/;
}

View File

@@ -19,6 +19,7 @@ interface CustomEditorProps {
description_html: string;
};
deleteFile: DeleteImage;
getAsset: any;
cancelUploadImage?: () => any;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
setShouldShowAlert?: (showAlert: boolean) => void;
@@ -37,6 +38,7 @@ export const useEditor = ({
uploadFile,
deleteFile,
cancelUploadImage,
getAsset,
editorProps = {},
value,
rerenderOnPropsChange,
@@ -64,7 +66,8 @@ export const useEditor = ({
},
deleteFile,
restoreFile,
cancelUploadImage
cancelUploadImage,
getAsset
),
...extensions,
],
@@ -84,6 +87,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

@@ -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";
@@ -24,10 +24,10 @@ import { CustomQuoteExtension } from "src/ui/extensions/quote";
import { DeleteImage } from "src/types/delete-image";
import { IMentionSuggestion } from "src/types/mention-suggestion";
import { RestoreImage } from "src/types/restore-image";
import { CustomLinkExtension } from "src/ui/extensions/custom-link";
import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline";
import { CustomTypographyExtension } from "src/ui/extensions/typography";
import { CustomLinkExtension } from "src/ui/extensions/custom-link";
import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule";
import { CustomTypographyExtension } from "src/ui/extensions/typography";
export const CoreEditorExtensions = (
mentionConfig: {
@@ -36,7 +36,8 @@ export const CoreEditorExtensions = (
},
deleteFile: DeleteImage,
restoreFile: RestoreImage,
cancelUploadImage?: () => any
cancelUploadImage?: () => any,
getAsset?: any
) => [
StarterKit.configure({
bulletList: {

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

@@ -27,6 +27,7 @@ export type IRichTextEditor = {
id: string;
description_html: string;
};
getAsset: any;
customClassName?: string;
editorContentCustomClassNames?: string;
onChange?: (json: any, html: string) => void;
@@ -59,6 +60,7 @@ const RichTextEditor = ({
value,
initialValue,
uploadFile,
getAsset,
deleteFile,
noBorder,
cancelUploadImage,
@@ -81,6 +83,7 @@ const RichTextEditor = ({
const editor = useEditor({
onChange,
getAsset,
debouncedUpdatesEnabled,
setIsSubmitting,
setShouldShowAlert,

View File

@@ -1,13 +1,13 @@
import { ChangeEvent, FC, useCallback, useEffect, useState } from "react";
import debounce from "lodash/debounce";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { RichReadOnlyEditor, RichTextEditor } from "@plane/rich-text-editor";
import { TIssue } from "@plane/types";
import debounce from "lodash/debounce";
import { observer } from "mobx-react";
import { ChangeEvent, FC, useCallback, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
// hooks
import { Loader, TextArea } from "@plane/ui";
import { useMention, useWorkspace } from "@/hooks/store";
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
import { Loader, TextArea } from "@plane/ui";
// components
// types
import { FileService } from "@/services/file.service";

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);
}