mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
Compare commits
14 Commits
chore/page
...
chore/file
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab5e698065 | ||
|
|
e09d7f9533 | ||
|
|
16c78100c0 | ||
|
|
2688e41cef | ||
|
|
6776c2d2d1 | ||
|
|
e19eb3f074 | ||
|
|
f95f24231f | ||
|
|
02e5e0da4b | ||
|
|
cedc08bc08 | ||
|
|
31cca8d07e | ||
|
|
6c97bcefbf | ||
|
|
e1f0da5e6c | ||
|
|
94f445cc08 | ||
|
|
42f307421a |
@@ -5,9 +5,7 @@ from .issue import (
|
||||
IssueSerializer,
|
||||
LabelSerializer,
|
||||
IssueLinkSerializer,
|
||||
IssueAttachmentSerializer,
|
||||
IssueCommentSerializer,
|
||||
IssueAttachmentSerializer,
|
||||
IssueActivitySerializer,
|
||||
IssueExpandSerializer,
|
||||
)
|
||||
|
||||
@@ -17,7 +17,6 @@ from plane.db.models import (
|
||||
IssueLabel,
|
||||
IssueLink,
|
||||
IssueComment,
|
||||
IssueAttachment,
|
||||
IssueActivity,
|
||||
ProjectMember,
|
||||
)
|
||||
@@ -296,22 +295,6 @@ class IssueLinkSerializer(BaseSerializer):
|
||||
return IssueLink.objects.create(**validated_data)
|
||||
|
||||
|
||||
class IssueAttachmentSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = IssueAttachment
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"workspace",
|
||||
"project",
|
||||
"issue",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class IssueCommentSerializer(BaseSerializer):
|
||||
is_member = serializers.BooleanField(read_only=True)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from plane.db.models import (
|
||||
Issue,
|
||||
CycleIssue,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
)
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.api.serializers import (
|
||||
@@ -430,8 +430,9 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# Python imports
|
||||
import json
|
||||
from itertools import chain
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
@@ -32,13 +31,13 @@ from plane.app.permissions import (
|
||||
)
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueAttachment,
|
||||
IssueLink,
|
||||
Project,
|
||||
Label,
|
||||
ProjectMember,
|
||||
IssueComment,
|
||||
IssueActivity,
|
||||
FileAsset,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.api.serializers import (
|
||||
@@ -127,8 +126,9 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
||||
@@ -19,8 +19,8 @@ from plane.db.models import (
|
||||
ModuleLink,
|
||||
Issue,
|
||||
ModuleIssue,
|
||||
IssueAttachment,
|
||||
IssueLink,
|
||||
FileAsset,
|
||||
)
|
||||
from plane.api.serializers import (
|
||||
ModuleSerializer,
|
||||
@@ -313,8 +313,9 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
||||
@@ -60,7 +60,6 @@ from .issue import (
|
||||
IssueStateSerializer,
|
||||
IssueLinkSerializer,
|
||||
IssueLiteSerializer,
|
||||
IssueAttachmentSerializer,
|
||||
IssueSubscriberSerializer,
|
||||
IssueReactionSerializer,
|
||||
CommentReactionSerializer,
|
||||
|
||||
@@ -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__"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from rest_framework import serializers
|
||||
from plane.settings.storage import S3PrivateBucketStorage
|
||||
|
||||
|
||||
class BaseSerializer(serializers.ModelSerializer):
|
||||
@@ -60,7 +61,7 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||
CycleIssueSerializer,
|
||||
IssueFlatSerializer,
|
||||
IssueRelationSerializer,
|
||||
InboxIssueLiteSerializer
|
||||
InboxIssueLiteSerializer,
|
||||
)
|
||||
|
||||
# Expansion mapper
|
||||
@@ -81,10 +82,22 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||
"issue_cycle": CycleIssueSerializer,
|
||||
"parent": IssueSerializer,
|
||||
"issue_relation": IssueRelationSerializer,
|
||||
"issue_inbox" : InboxIssueLiteSerializer,
|
||||
"issue_inbox": InboxIssueLiteSerializer,
|
||||
}
|
||||
|
||||
self.fields[field] = expansion[field](many=True if field in ["members", "assignees", "labels", "issue_cycle", "issue_relation", "issue_inbox"] else False)
|
||||
|
||||
self.fields[field] = expansion[field](
|
||||
many=True
|
||||
if field
|
||||
in [
|
||||
"members",
|
||||
"assignees",
|
||||
"labels",
|
||||
"issue_cycle",
|
||||
"issue_relation",
|
||||
"issue_inbox",
|
||||
]
|
||||
else False
|
||||
)
|
||||
|
||||
return self.fields
|
||||
|
||||
@@ -105,7 +118,7 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||
LabelSerializer,
|
||||
CycleIssueSerializer,
|
||||
IssueRelationSerializer,
|
||||
InboxIssueLiteSerializer
|
||||
InboxIssueLiteSerializer,
|
||||
)
|
||||
|
||||
# Expansion mapper
|
||||
@@ -126,7 +139,7 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||
"issue_cycle": CycleIssueSerializer,
|
||||
"parent": IssueSerializer,
|
||||
"issue_relation": IssueRelationSerializer,
|
||||
"issue_inbox" : InboxIssueLiteSerializer,
|
||||
"issue_inbox": InboxIssueLiteSerializer,
|
||||
}
|
||||
# Check if field in expansion then expand the field
|
||||
if expand in expansion:
|
||||
@@ -146,3 +159,22 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class BaseFileSerializer(DynamicBaseSerializer):
|
||||
|
||||
class Meta:
|
||||
abstract = True # Make this serializer abstract
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""
|
||||
Object instance -> Dict of primitive datatypes.
|
||||
"""
|
||||
response = super().to_representation(instance)
|
||||
response[
|
||||
"asset"
|
||||
] = (
|
||||
instance.asset.name
|
||||
) # Ensure 'asset' field is consistently serialized
|
||||
# Apply custom method to get download URL
|
||||
return response
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.utils import timezone
|
||||
from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
from .base import BaseSerializer, DynamicBaseSerializer
|
||||
from .base import BaseSerializer, DynamicBaseSerializer, BaseFileSerializer
|
||||
from .user import UserLiteSerializer
|
||||
from .state import StateSerializer, StateLiteSerializer
|
||||
from .project import ProjectLiteSerializer
|
||||
@@ -25,7 +25,7 @@ from plane.db.models import (
|
||||
Module,
|
||||
ModuleIssue,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
IssueReaction,
|
||||
CommentReaction,
|
||||
IssueVote,
|
||||
@@ -444,21 +444,6 @@ class IssueLinkSerializer(BaseSerializer):
|
||||
return IssueLink.objects.create(**validated_data)
|
||||
|
||||
|
||||
class IssueAttachmentSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = IssueAttachment
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"workspace",
|
||||
"project",
|
||||
"issue",
|
||||
]
|
||||
|
||||
|
||||
class IssueReactionSerializer(BaseSerializer):
|
||||
actor_detail = UserLiteSerializer(read_only=True, source="actor")
|
||||
|
||||
@@ -503,9 +488,7 @@ class IssueCommentSerializer(BaseSerializer):
|
||||
workspace_detail = WorkspaceLiteSerializer(
|
||||
read_only=True, source="workspace"
|
||||
)
|
||||
comment_reactions = CommentReactionSerializer(
|
||||
read_only=True, many=True
|
||||
)
|
||||
comment_reactions = CommentReactionSerializer(read_only=True, many=True)
|
||||
is_member = serializers.BooleanField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
@@ -615,7 +598,10 @@ class IssueSerializer(DynamicBaseSerializer):
|
||||
|
||||
def get_module_ids(self, obj):
|
||||
# Access the prefetched modules and extract module IDs
|
||||
return [module for module in obj.issue_module.values_list("module_id", flat=True)]
|
||||
return [
|
||||
module
|
||||
for module in obj.issue_module.values_list("module_id", flat=True)
|
||||
]
|
||||
|
||||
|
||||
class IssueLiteSerializer(DynamicBaseSerializer):
|
||||
|
||||
@@ -4,7 +4,6 @@ from rest_framework import serializers
|
||||
# Module import
|
||||
from .base import BaseSerializer
|
||||
from plane.db.models import User, Workspace, WorkspaceMemberInvite
|
||||
from plane.license.models import InstanceAdmin, Instance
|
||||
|
||||
|
||||
class UserSerializer(BaseSerializer):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -21,6 +21,7 @@ from plane.app.views import (
|
||||
IssueArchiveViewSet,
|
||||
IssueRelationViewSet,
|
||||
IssueDraftViewSet,
|
||||
CommentAssetEndpoint,
|
||||
)
|
||||
|
||||
|
||||
@@ -117,12 +118,12 @@ urlpatterns = [
|
||||
name="project-issue-links",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/",
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/",
|
||||
IssueAttachmentEndpoint.as_view(),
|
||||
name="project-issue-attachments",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/<uuid:pk>/",
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/<uuid:workspace_id>/<str:asset_key>/",
|
||||
IssueAttachmentEndpoint.as_view(),
|
||||
name="project-issue-attachments",
|
||||
),
|
||||
@@ -313,4 +314,16 @@ urlpatterns = [
|
||||
),
|
||||
name="project-issue-draft",
|
||||
),
|
||||
# Comment Assets
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/comments/<uuid:comment_id>/attachments/",
|
||||
CommentAssetEndpoint.as_view(),
|
||||
name="project-comment-attachments",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/comments/<uuid:comment_id>/attachments/<uuid:workspace_id>/<str:asset_key>/",
|
||||
CommentAssetEndpoint.as_view(),
|
||||
name="project-comment-attachments",
|
||||
),
|
||||
## End Comments
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -14,6 +14,7 @@ from plane.app.views import (
|
||||
ProjectPublicCoverImagesEndpoint,
|
||||
ProjectDeployBoardViewSet,
|
||||
UserProjectRolesEndpoint,
|
||||
ProjectCoverImageEndpoint,
|
||||
)
|
||||
|
||||
|
||||
@@ -175,4 +176,14 @@ urlpatterns = [
|
||||
),
|
||||
name="project-deploy-board",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/cover-image/<str:workspace_id>/<str:cover_image_key>/",
|
||||
ProjectCoverImageEndpoint.as_view(),
|
||||
name="project-cover-image",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/cover-image/",
|
||||
ProjectCoverImageEndpoint.as_view(),
|
||||
name="project-cover-image",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -22,6 +22,7 @@ from plane.app.views import (
|
||||
WorkspaceUserPropertiesEndpoint,
|
||||
WorkspaceStatesEndpoint,
|
||||
WorkspaceEstimatesEndpoint,
|
||||
WorkspaceLogoEndpoint,
|
||||
)
|
||||
|
||||
|
||||
@@ -219,4 +220,14 @@ urlpatterns = [
|
||||
WorkspaceEstimatesEndpoint.as_view(),
|
||||
name="workspace-estimate",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/logo/",
|
||||
WorkspaceLogoEndpoint.as_view(),
|
||||
name="workspace-logo",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/logo/<str:workspace_id>/<str:logo_key>/",
|
||||
WorkspaceLogoEndpoint.as_view(),
|
||||
name="workspace-logo",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -12,12 +12,15 @@ from .project import (
|
||||
ProjectPublicCoverImagesEndpoint,
|
||||
ProjectDeployBoardViewSet,
|
||||
UserProjectRolesEndpoint,
|
||||
ProjectCoverImageEndpoint,
|
||||
)
|
||||
from .user import (
|
||||
UserEndpoint,
|
||||
UpdateUserOnBoardedEndpoint,
|
||||
UpdateUserTourCompletedEndpoint,
|
||||
UserActivityEndpoint,
|
||||
UserAvatarEndpoint,
|
||||
UserCoverImageEndpoint,
|
||||
)
|
||||
|
||||
from .oauth import OauthEndpoint
|
||||
@@ -49,6 +52,7 @@ from .workspace import (
|
||||
WorkspaceUserPropertiesEndpoint,
|
||||
WorkspaceStatesEndpoint,
|
||||
WorkspaceEstimatesEndpoint,
|
||||
WorkspaceLogoEndpoint,
|
||||
)
|
||||
from .state import StateViewSet
|
||||
from .view import (
|
||||
@@ -65,7 +69,7 @@ from .cycle import (
|
||||
TransferCycleIssueEndpoint,
|
||||
CycleUserPropertiesEndpoint,
|
||||
)
|
||||
from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
|
||||
from .asset import FileAssetEndpoint, FileAssetViewSet
|
||||
from .issue import (
|
||||
IssueViewSet,
|
||||
WorkSpaceIssuesEndpoint,
|
||||
@@ -85,6 +89,7 @@ from .issue import (
|
||||
IssueReactionViewSet,
|
||||
IssueRelationViewSet,
|
||||
IssueDraftViewSet,
|
||||
CommentAssetEndpoint,
|
||||
)
|
||||
|
||||
from .auth_extended import (
|
||||
@@ -137,6 +142,7 @@ from .page import (
|
||||
PageFavoriteViewSet,
|
||||
PageLogEndpoint,
|
||||
SubPagesEndpoint,
|
||||
PageAssetEndpoint,
|
||||
)
|
||||
|
||||
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
|
||||
@@ -180,7 +186,4 @@ from .webhook import (
|
||||
WebhookSecretRegenerateEndpoint,
|
||||
)
|
||||
|
||||
from .dashboard import (
|
||||
DashboardEndpoint,
|
||||
WidgetsEndpoint
|
||||
)
|
||||
from .dashboard import DashboardEndpoint, WidgetsEndpoint
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -47,10 +47,10 @@ from plane.db.models import (
|
||||
Issue,
|
||||
CycleFavorite,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
Label,
|
||||
CycleUserProperties,
|
||||
IssueSubscriber,
|
||||
FileAsset,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
@@ -611,7 +611,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
|
||||
@@ -32,7 +32,7 @@ from plane.db.models import (
|
||||
Dashboard,
|
||||
Project,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
IssueRelation,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
@@ -117,8 +117,9 @@ def dashboard_assigned_issues(self, request, slug):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
@@ -246,8 +247,9 @@ def dashboard_created_issues(self, request, slug):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
||||
@@ -19,7 +19,7 @@ from plane.db.models import (
|
||||
Issue,
|
||||
State,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
@@ -92,7 +92,7 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
Issue.objects.filter(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
issue_inbox__inbox_id=self.kwargs.get("inbox_id")
|
||||
issue_inbox__inbox_id=self.kwargs.get("inbox_id"),
|
||||
)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
@@ -112,8 +112,9 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
@@ -131,8 +132,14 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
|
||||
def list(self, request, slug, project_id, inbox_id):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issue_queryset = self.get_queryset().filter(**filters).order_by("issue_inbox__snoozed_till", "issue_inbox__status")
|
||||
issues_data = IssueSerializer(issue_queryset, expand=self.expand, many=True).data
|
||||
issue_queryset = (
|
||||
self.get_queryset()
|
||||
.filter(**filters)
|
||||
.order_by("issue_inbox__snoozed_till", "issue_inbox__status")
|
||||
)
|
||||
issues_data = IssueSerializer(
|
||||
issue_queryset, expand=self.expand, many=True
|
||||
).data
|
||||
return Response(
|
||||
issues_data,
|
||||
status=status.HTTP_200_OK,
|
||||
@@ -199,8 +206,8 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
source=request.data.get("source", "in-app"),
|
||||
)
|
||||
|
||||
issue = (self.get_queryset().filter(pk=issue.id).first())
|
||||
serializer = IssueSerializer(issue ,expand=self.expand)
|
||||
issue = self.get_queryset().filter(pk=issue.id).first()
|
||||
serializer = IssueSerializer(issue, expand=self.expand)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def partial_update(self, request, slug, project_id, inbox_id, issue_id):
|
||||
@@ -320,20 +327,23 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
if state is not None:
|
||||
issue.state = state
|
||||
issue.save()
|
||||
issue = (self.get_queryset().filter(pk=issue_id).first())
|
||||
issue = self.get_queryset().filter(pk=issue_id).first()
|
||||
serializer = IssueSerializer(issue, expand=self.expand)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(
|
||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
else:
|
||||
issue = (self.get_queryset().filter(pk=issue_id).first())
|
||||
serializer = IssueSerializer(issue ,expand=self.expand)
|
||||
issue = self.get_queryset().filter(pk=issue_id).first()
|
||||
serializer = IssueSerializer(issue, expand=self.expand)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def retrieve(self, request, slug, project_id, inbox_id, issue_id):
|
||||
issue = self.get_queryset().filter(pk=issue_id).first()
|
||||
serializer = IssueSerializer(issue, expand=self.expand,)
|
||||
serializer = IssueSerializer(
|
||||
issue,
|
||||
expand=self.expand,
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def destroy(self, request, slug, project_id, inbox_id, issue_id):
|
||||
|
||||
@@ -4,7 +4,7 @@ import random
|
||||
from itertools import chain
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.utils import timezone
|
||||
from django.db.models import (
|
||||
Prefetch,
|
||||
@@ -29,7 +29,7 @@ from django.db import IntegrityError
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.parsers import MultiPartParser, FormParser
|
||||
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||
|
||||
# Module imports
|
||||
from . import BaseViewSet, BaseAPIView, WebhookMixin
|
||||
@@ -43,13 +43,13 @@ from plane.app.serializers import (
|
||||
IssueFlatSerializer,
|
||||
IssueLinkSerializer,
|
||||
IssueLiteSerializer,
|
||||
IssueAttachmentSerializer,
|
||||
IssueSubscriberSerializer,
|
||||
ProjectMemberLiteSerializer,
|
||||
IssueReactionSerializer,
|
||||
CommentReactionSerializer,
|
||||
IssueRelationSerializer,
|
||||
RelatedIssueSerializer,
|
||||
FileAssetSerializer,
|
||||
)
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
@@ -58,6 +58,7 @@ from plane.app.permissions import (
|
||||
ProjectLitePermission,
|
||||
)
|
||||
from plane.db.models import (
|
||||
Workspace,
|
||||
Project,
|
||||
Issue,
|
||||
IssueActivity,
|
||||
@@ -65,7 +66,7 @@ from plane.db.models import (
|
||||
IssueProperty,
|
||||
Label,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
State,
|
||||
IssueSubscriber,
|
||||
ProjectMember,
|
||||
@@ -75,10 +76,12 @@ from plane.db.models import (
|
||||
IssueVote,
|
||||
IssueRelation,
|
||||
ProjectPublicMember,
|
||||
FileAsset,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.grouper import group_results
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.presigned_url_generator import generate_download_presigned_url
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
@@ -128,8 +131,9 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
@@ -372,8 +376,9 @@ class UserWorkSpaceIssues(BaseAPIView):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
@@ -792,8 +797,9 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
@@ -1010,17 +1016,24 @@ class BulkCreateIssueLabelsEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class IssueAttachmentEndpoint(BaseAPIView):
|
||||
serializer_class = IssueAttachmentSerializer
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
model = IssueAttachment
|
||||
parser_classes = (MultiPartParser, FormParser)
|
||||
parser_classes = (
|
||||
MultiPartParser,
|
||||
FormParser,
|
||||
JSONParser,
|
||||
)
|
||||
|
||||
def post(self, request, slug, project_id, issue_id):
|
||||
serializer = IssueAttachmentSerializer(data=request.data)
|
||||
serializer = FileAssetSerializer(data=request.data)
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
if serializer.is_valid():
|
||||
serializer.save(project_id=project_id, issue_id=issue_id)
|
||||
serializer.save(
|
||||
workspace=workspace,
|
||||
project_id=project_id,
|
||||
entity_identifier=issue_id,
|
||||
)
|
||||
issue_activity.delay(
|
||||
type="attachment.activity.created",
|
||||
requested_data=None,
|
||||
@@ -1038,10 +1051,19 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(self, request, slug, project_id, issue_id, pk):
|
||||
issue_attachment = IssueAttachment.objects.get(pk=pk)
|
||||
issue_attachment.asset.delete(save=False)
|
||||
issue_attachment.delete()
|
||||
def delete(
|
||||
self, request, slug, project_id, issue_id, workspace_id, asset_key
|
||||
):
|
||||
key = f"{workspace_id}/{asset_key}"
|
||||
asset = FileAsset.objects.get(
|
||||
asset=key,
|
||||
entity_identifier=issue_id,
|
||||
entity_type="issue_attachment",
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
asset.is_deleted = True
|
||||
asset.save()
|
||||
issue_activity.delay(
|
||||
type="attachment.activity.deleted",
|
||||
requested_data=None,
|
||||
@@ -1053,14 +1075,34 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def get(self, request, slug, project_id, issue_id):
|
||||
issue_attachments = IssueAttachment.objects.filter(
|
||||
issue_id=issue_id, workspace__slug=slug, project_id=project_id
|
||||
def get(
|
||||
self,
|
||||
request,
|
||||
slug,
|
||||
project_id,
|
||||
issue_id,
|
||||
workspace_id=None,
|
||||
asset_key=None,
|
||||
):
|
||||
if workspace_id and asset_key:
|
||||
key = f"{workspace_id}/{asset_key}"
|
||||
url = generate_download_presigned_url(
|
||||
key=key,
|
||||
host=request.get_host(),
|
||||
scheme=request.scheme,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
# For listing
|
||||
issue_attachments = FileAsset.objects.filter(
|
||||
entity_type="issue_attachment",
|
||||
entity_identifier=issue_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
serializer = IssueAttachmentSerializer(issue_attachments, many=True)
|
||||
serializer = FileAssetSerializer(issue_attachments, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@@ -1084,7 +1126,7 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
@@ -1092,8 +1134,9 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
@@ -1131,10 +1174,7 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
|
||||
issue_queryset = (
|
||||
self.get_queryset()
|
||||
.filter(**filters)
|
||||
)
|
||||
issue_queryset = self.get_queryset().filter(**filters)
|
||||
|
||||
# Priority Ordering
|
||||
if order_by_param == "priority" or order_by_param == "-priority":
|
||||
@@ -1579,15 +1619,17 @@ class IssueRelationViewSet(BaseViewSet):
|
||||
issue_relation = IssueRelation.objects.bulk_create(
|
||||
[
|
||||
IssueRelation(
|
||||
issue_id=issue
|
||||
if relation_type == "blocking"
|
||||
else issue_id,
|
||||
related_issue_id=issue_id
|
||||
if relation_type == "blocking"
|
||||
else issue,
|
||||
relation_type="blocked_by"
|
||||
if relation_type == "blocking"
|
||||
else relation_type,
|
||||
issue_id=(
|
||||
issue if relation_type == "blocking" else issue_id
|
||||
),
|
||||
related_issue_id=(
|
||||
issue_id if relation_type == "blocking" else issue
|
||||
),
|
||||
relation_type=(
|
||||
"blocked_by"
|
||||
if relation_type == "blocking"
|
||||
else relation_type
|
||||
),
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
@@ -1689,8 +1731,9 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
@@ -1727,10 +1770,7 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
|
||||
issue_queryset = (
|
||||
self.get_queryset()
|
||||
.filter(**filters)
|
||||
)
|
||||
issue_queryset = self.get_queryset().filter(**filters)
|
||||
|
||||
# Priority Ordering
|
||||
if order_by_param == "priority" or order_by_param == "-priority":
|
||||
@@ -1893,3 +1933,70 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class CommentAssetEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectLitePermission,
|
||||
]
|
||||
parser_classes = (
|
||||
MultiPartParser,
|
||||
FormParser,
|
||||
JSONParser,
|
||||
)
|
||||
|
||||
def post(self, request, slug, project_id, comment_id):
|
||||
serializer = FileAssetSerializer(data=request.data)
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
workspace=workspace,
|
||||
project_id=project_id,
|
||||
entity_type="comment",
|
||||
entity_identifier=comment_id,
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(
|
||||
self, request, slug, project_id, comment_id, workspace_id, asset_key
|
||||
):
|
||||
key = f"{workspace_id}/{asset_key}"
|
||||
asset = FileAsset.objects.get(
|
||||
asset=key,
|
||||
entity_identifier=comment_id,
|
||||
entity_type="comment",
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
asset.is_deleted = True
|
||||
asset.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def get(
|
||||
self,
|
||||
request,
|
||||
slug,
|
||||
project_id,
|
||||
comment_id,
|
||||
workspace_id=None,
|
||||
asset_key=None,
|
||||
):
|
||||
if workspace_id and asset_key:
|
||||
key = f"{workspace_id}/{asset_key}"
|
||||
url = generate_download_presigned_url(
|
||||
key=key,
|
||||
host=request.get_host(),
|
||||
scheme=request.scheme,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
# For listing
|
||||
comment_assets = FileAsset.objects.filter(
|
||||
entity_type="comment",
|
||||
entity_identifier=comment_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
serializer = FileAssetSerializer(comment_assets, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -37,7 +37,7 @@ from plane.db.models import (
|
||||
ModuleLink,
|
||||
ModuleFavorite,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
IssueSubscriber,
|
||||
ModuleUserProperties,
|
||||
)
|
||||
@@ -331,17 +331,16 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
issue_module__module_id=self.kwargs.get("module_id")
|
||||
issue_module__module_id=self.kwargs.get("module_id"),
|
||||
)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("labels", "assignees")
|
||||
.prefetch_related('issue_module__module')
|
||||
.prefetch_related("issue_module__module")
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
@@ -350,8 +349,9 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
@@ -420,10 +420,9 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
for issue in issues
|
||||
]
|
||||
issues = (self.get_queryset().filter(pk__in=issues))
|
||||
serializer = IssueSerializer(issues , many=True)
|
||||
issues = self.get_queryset().filter(pk__in=issues)
|
||||
serializer = IssueSerializer(issues, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
# create multiple module inside an issue
|
||||
def create_issue_modules(self, request, slug, project_id, issue_id):
|
||||
@@ -466,11 +465,10 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
for module in modules
|
||||
]
|
||||
|
||||
issue = (self.get_queryset().filter(pk=issue_id).first())
|
||||
issue = self.get_queryset().filter(pk=issue_id).first()
|
||||
serializer = IssueSerializer(issue)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
def destroy(self, request, slug, project_id, module_id, issue_id):
|
||||
module_issue = ModuleIssue.objects.get(
|
||||
workspace__slug=slug,
|
||||
@@ -484,7 +482,9 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue_id),
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps({"module_name": module_issue.module.name}),
|
||||
current_instance=json.dumps(
|
||||
{"module_name": module_issue.module.name}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
|
||||
@@ -4,23 +4,34 @@ from datetime import date, datetime, timedelta
|
||||
# Django imports
|
||||
from django.db import connection
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.utils import timezone
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.app.serializers import (IssueLiteSerializer, PageFavoriteSerializer,
|
||||
PageLogSerializer, PageSerializer,
|
||||
SubPageSerializer)
|
||||
from plane.db.models import (Issue, IssueActivity, IssueAssignee, Page,
|
||||
PageFavorite, PageLog, ProjectMember)
|
||||
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||
|
||||
# Module imports
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.app.serializers import (
|
||||
PageFavoriteSerializer,
|
||||
PageLogSerializer,
|
||||
PageSerializer,
|
||||
SubPageSerializer,
|
||||
FileAssetSerializer,
|
||||
)
|
||||
from plane.db.models import (
|
||||
Workspace,
|
||||
Page,
|
||||
PageFavorite,
|
||||
PageLog,
|
||||
ProjectMember,
|
||||
FileAsset,
|
||||
)
|
||||
from .base import BaseAPIView, BaseViewSet
|
||||
|
||||
from plane.utils.presigned_url_generator import generate_download_presigned_url
|
||||
|
||||
def unarchive_archive_page_and_descendants(page_id, archived_at):
|
||||
# Your SQL query
|
||||
@@ -348,3 +359,70 @@ class SubPagesEndpoint(BaseAPIView):
|
||||
return Response(
|
||||
SubPageSerializer(pages, many=True).data, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
|
||||
class PageAssetEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
parser_classes = (
|
||||
MultiPartParser,
|
||||
FormParser,
|
||||
JSONParser,
|
||||
)
|
||||
|
||||
def post(self, request, slug, project_id, page_id):
|
||||
serializer = FileAssetSerializer(data=request.data)
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
workspace=workspace,
|
||||
project_id=project_id,
|
||||
entity_type="page",
|
||||
entity_identifier=page_id,
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(
|
||||
self, request, slug, project_id, page_id, workspace_id, asset_key
|
||||
):
|
||||
key = f"{workspace_id}/{asset_key}"
|
||||
asset = FileAsset.objects.get(
|
||||
asset=key,
|
||||
entity_identifier=page_id,
|
||||
entity_type="page",
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
asset.is_deleted = True
|
||||
asset.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def get(
|
||||
self,
|
||||
request,
|
||||
slug,
|
||||
project_id,
|
||||
page_id,
|
||||
workspace_id=None,
|
||||
asset_key=None,
|
||||
):
|
||||
if workspace_id and asset_key:
|
||||
key = f"{workspace_id}/{asset_key}"
|
||||
url = generate_download_presigned_url(
|
||||
key=key,
|
||||
host=request.get_host(),
|
||||
scheme=request.scheme,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
# For listing
|
||||
page_assets = FileAsset.objects.filter(
|
||||
entity_type="page",
|
||||
entity_identifier=page_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
serializer = FileAssetSerializer(page_assets, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -18,12 +18,13 @@ from django.db.models import (
|
||||
from django.core.validators import validate_email
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from django.http import HttpResponseRedirect
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework import serializers
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||
|
||||
# Module imports
|
||||
from .base import BaseViewSet, BaseAPIView, WebhookMixin
|
||||
@@ -37,6 +38,8 @@ from plane.app.serializers import (
|
||||
ProjectDeployBoardSerializer,
|
||||
ProjectMemberAdminSerializer,
|
||||
ProjectMemberRoleSerializer,
|
||||
ProjectLiteSerializer,
|
||||
FileAssetSerializer,
|
||||
)
|
||||
|
||||
from plane.app.permissions import (
|
||||
@@ -62,10 +65,10 @@ from plane.db.models import (
|
||||
Inbox,
|
||||
ProjectDeployBoard,
|
||||
IssueProperty,
|
||||
FileAsset,
|
||||
)
|
||||
|
||||
from plane.bgtasks.project_invitation_task import project_invitation
|
||||
|
||||
from plane.utils.presigned_url_generator import generate_download_presigned_url
|
||||
|
||||
class ProjectViewSet(WebhookMixin, BaseViewSet):
|
||||
serializer_class = ProjectListSerializer
|
||||
@@ -1138,3 +1141,49 @@ class UserProjectRolesEndpoint(BaseAPIView):
|
||||
for member in project_members
|
||||
}
|
||||
return Response(project_members, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class ProjectCoverImageEndpoint(BaseAPIView):
|
||||
|
||||
parser_classes = (
|
||||
MultiPartParser,
|
||||
FormParser,
|
||||
JSONParser,
|
||||
)
|
||||
|
||||
def get_permissions(self):
|
||||
if self.request.method == "POST" or self.request.method == "DELETE":
|
||||
return [
|
||||
IsAuthenticated(),
|
||||
]
|
||||
return [
|
||||
AllowAny(),
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id, workspace_id, cover_image_key):
|
||||
key = f"{workspace_id}/{cover_image_key}"
|
||||
url = generate_download_presigned_url(
|
||||
key=key,
|
||||
host=request.get_host(),
|
||||
scheme=request.scheme,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
def post(self, request, slug, project_id):
|
||||
serializer = FileAssetSerializer(data=request.data)
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
if serializer.is_valid():
|
||||
serializer.save(workspace=workspace)
|
||||
project = Project.objects.get(pk=project_id)
|
||||
project.cover_image = f"/api/workspaces/{slug}/projects/{project_id}/cover-image/{serializer.data['asset']}/"
|
||||
project.save()
|
||||
project_serializer = ProjectLiteSerializer(project)
|
||||
return Response(project_serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(self, request, project_id, workspace_id, cover_image_key):
|
||||
key = f"{workspace_id}/{cover_image_key}"
|
||||
file_asset = FileAsset.objects.get(asset=key)
|
||||
file_asset.is_deleted = True
|
||||
file_asset.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
# Python imports
|
||||
import requests
|
||||
|
||||
# Django imports
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
import boto3
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
|
||||
# Module imports
|
||||
from plane.app.serializers import (
|
||||
@@ -9,13 +18,14 @@ from plane.app.serializers import (
|
||||
IssueActivitySerializer,
|
||||
UserMeSerializer,
|
||||
UserMeSettingsSerializer,
|
||||
FileAssetSerializer,
|
||||
)
|
||||
|
||||
from plane.app.views.base import BaseViewSet, BaseAPIView
|
||||
from plane.db.models import User, IssueActivity, WorkspaceMember, ProjectMember
|
||||
from plane.db.models import User, IssueActivity, WorkspaceMember, ProjectMember, FileAsset
|
||||
from plane.license.models import Instance, InstanceAdmin
|
||||
from plane.utils.paginator import BasePaginator
|
||||
|
||||
from plane.utils.presigned_url_generator import generate_download_presigned_url
|
||||
|
||||
from django.db.models import Q, F, Count, Case, When, IntegerField
|
||||
|
||||
@@ -177,3 +187,92 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator):
|
||||
issue_activities, many=True
|
||||
).data,
|
||||
)
|
||||
|
||||
|
||||
class UserAvatarEndpoint(BaseAPIView):
|
||||
|
||||
parser_classes = (
|
||||
MultiPartParser,
|
||||
FormParser,
|
||||
JSONParser,
|
||||
)
|
||||
|
||||
def get_permissions(self):
|
||||
if self.request.method == "POST" or self.request.method == "DELETE":
|
||||
return [
|
||||
IsAuthenticated(),
|
||||
]
|
||||
return [
|
||||
AllowAny(),
|
||||
]
|
||||
|
||||
|
||||
def get(self, request, avatar_key):
|
||||
url = generate_download_presigned_url(
|
||||
key=avatar_key,
|
||||
host=request.get_host(),
|
||||
scheme=request.scheme,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
def post(self, request):
|
||||
serializer = FileAssetSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
# Get the workspace
|
||||
serializer.save()
|
||||
user = request.user
|
||||
user.avatar = "/api/users/avatar/" + serializer.data["asset"] + "/"
|
||||
user.save()
|
||||
user_serializer = UserMeSerializer(user)
|
||||
return Response(user_serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(self, request, avatar_key):
|
||||
file_asset = FileAsset.objects.get(asset=avatar_key)
|
||||
file_asset.is_deleted = True
|
||||
file_asset.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class UserCoverImageEndpoint(BaseAPIView):
|
||||
|
||||
parser_classes = (
|
||||
MultiPartParser,
|
||||
FormParser,
|
||||
JSONParser,
|
||||
)
|
||||
|
||||
def get_permissions(self):
|
||||
if self.request.method == "POST" or self.request.method == "DELETE":
|
||||
return [
|
||||
IsAuthenticated(),
|
||||
]
|
||||
return [
|
||||
AllowAny(),
|
||||
]
|
||||
|
||||
def get(self, request, cover_image_key):
|
||||
url = generate_download_presigned_url(
|
||||
key=cover_image_key,
|
||||
host=request.get_host(),
|
||||
scheme=request.scheme,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
def post(self, request):
|
||||
serializer = FileAssetSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
# Get the workspace
|
||||
serializer.save()
|
||||
user = request.user
|
||||
user.avatar = "/api/users/cover-image/" + serializer.data["asset"] + "/"
|
||||
user.save()
|
||||
user_serializer = UserMeSerializer(user)
|
||||
return Response(user_serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(self, request, cover_image_key):
|
||||
file_asset = FileAsset.objects.get(asset=cover_image_key)
|
||||
file_asset.is_deleted = True
|
||||
file_asset.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -41,7 +41,7 @@ from plane.db.models import (
|
||||
IssueViewFavorite,
|
||||
IssueReaction,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
IssueSubscriber,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
@@ -130,8 +130,9 @@ class GlobalViewIssuesViewSet(BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
||||
@@ -25,11 +25,13 @@ from django.db.models import (
|
||||
)
|
||||
from django.db.models.functions import ExtractWeek, Cast, ExtractDay
|
||||
from django.db.models.fields import DateField
|
||||
from django.http import HttpResponseRedirect
|
||||
|
||||
# Third party modules
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||
|
||||
# Module imports
|
||||
from plane.app.serializers import (
|
||||
@@ -49,6 +51,7 @@ from plane.app.serializers import (
|
||||
WorkspaceEstimateSerializer,
|
||||
StateSerializer,
|
||||
LabelSerializer,
|
||||
FileAssetSerializer,
|
||||
)
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from . import BaseViewSet
|
||||
@@ -63,7 +66,7 @@ from plane.db.models import (
|
||||
Issue,
|
||||
WorkspaceTheme,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
IssueSubscriber,
|
||||
Project,
|
||||
Label,
|
||||
@@ -73,6 +76,7 @@ from plane.db.models import (
|
||||
WorkspaceUserProperties,
|
||||
Estimate,
|
||||
EstimatePoint,
|
||||
FileAsset,
|
||||
)
|
||||
from plane.app.permissions import (
|
||||
WorkSpaceBasePermission,
|
||||
@@ -84,6 +88,7 @@ from plane.app.permissions import (
|
||||
)
|
||||
from plane.bgtasks.workspace_invitation_task import workspace_invitation
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.presigned_url_generator import generate_download_presigned_url
|
||||
from plane.bgtasks.event_tracking_task import workspace_invite_event
|
||||
|
||||
|
||||
@@ -1355,8 +1360,9 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
@@ -1524,3 +1530,52 @@ class WorkspaceUserPropertiesEndpoint(BaseAPIView):
|
||||
)
|
||||
serializer = WorkspaceUserPropertiesSerializer(workspace_properties)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class WorkspaceLogoEndpoint(BaseAPIView):
|
||||
|
||||
parser_classes = (
|
||||
MultiPartParser,
|
||||
FormParser,
|
||||
JSONParser,
|
||||
)
|
||||
|
||||
def get_permissions(self):
|
||||
if self.request.method == "POST" or self.request.method == "DELETE":
|
||||
return [
|
||||
IsAuthenticated(),
|
||||
]
|
||||
return [
|
||||
AllowAny(),
|
||||
]
|
||||
|
||||
def get(self, request, slug, workspace_id, logo_key):
|
||||
key = f"{workspace_id}/{logo_key}"
|
||||
url = generate_download_presigned_url(
|
||||
key=key,
|
||||
host=request.get_host(),
|
||||
scheme=request.scheme,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
def post(self, request, slug):
|
||||
serializer = FileAssetSerializer(data=request.data)
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
if serializer.is_valid():
|
||||
serializer.save(workspace=workspace)
|
||||
workspace.logo = (
|
||||
f"/api/workspaces/{slug}/logo/{serializer.data['asset']}/"
|
||||
)
|
||||
workspace.save()
|
||||
workspace_serializer = WorkSpaceSerializer(workspace)
|
||||
return Response(
|
||||
workspace_serializer.data, status=status.HTTP_201_CREATED
|
||||
)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(self, request, slug, workspace_id, logo_key):
|
||||
key = f"{workspace_id}/{logo_key}"
|
||||
file_asset = FileAsset.objects.get(asset=key)
|
||||
file_asset.is_deleted = True
|
||||
file_asset.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -11,32 +11,18 @@ from django.conf import settings
|
||||
class Command(BaseCommand):
|
||||
help = "Create the default bucket for the instance"
|
||||
|
||||
def set_bucket_public_policy(self, s3_client, bucket_name):
|
||||
public_policy = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": [f"arn:aws:s3:::{bucket_name}/*"],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def set_bucket_private_policy(self, s3_client, bucket_name):
|
||||
try:
|
||||
s3_client.put_bucket_policy(
|
||||
Bucket=bucket_name, Policy=json.dumps(public_policy)
|
||||
)
|
||||
s3_client.delete_bucket_policy(Bucket=bucket_name)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Public read access policy set for bucket '{bucket_name}'."
|
||||
f"Public access policy removed for bucket '{bucket_name}', bucket is now private."
|
||||
)
|
||||
)
|
||||
except ClientError as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f"Error setting public read access policy: {e}"
|
||||
f"Error removing public access policy: {e}"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -58,7 +44,7 @@ class Command(BaseCommand):
|
||||
# Check if the bucket exists
|
||||
s3_client.head_bucket(Bucket=bucket_name)
|
||||
|
||||
self.set_bucket_public_policy(s3_client, bucket_name)
|
||||
self.set_bucket_private_policy(s3_client, bucket_name)
|
||||
except ClientError as e:
|
||||
error_code = int(e.response["Error"]["Code"])
|
||||
bucket_name = settings.AWS_STORAGE_BUCKET_NAME
|
||||
@@ -76,7 +62,6 @@ class Command(BaseCommand):
|
||||
f"Bucket '{bucket_name}' created successfully."
|
||||
)
|
||||
)
|
||||
self.set_bucket_public_policy(s3_client, bucket_name)
|
||||
except ClientError as create_error:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
|
||||
248
apiserver/plane/db/migrations/0059_auto_20240131_1334.py
Normal file
248
apiserver/plane/db/migrations/0059_auto_20240131_1334.py
Normal file
@@ -0,0 +1,248 @@
|
||||
# Generated by Django 4.2.7 on 2024-01-31 13:34
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
from django.conf import settings
|
||||
import django.db.models
|
||||
import plane.db.models.asset
|
||||
|
||||
|
||||
def update_user_urls(apps, schema_editor):
|
||||
# Check if the app is using minio or s3
|
||||
if settings.USE_MINIO:
|
||||
prefix1 = (
|
||||
f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/"
|
||||
)
|
||||
prefix2 = prefix1
|
||||
else:
|
||||
prefix1 = f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/"
|
||||
prefix2 = (
|
||||
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com/"
|
||||
)
|
||||
|
||||
User = apps.get_model("db", "User")
|
||||
bulk_users = []
|
||||
|
||||
# Loop through all the users and update the cover image
|
||||
for user in User.objects.all():
|
||||
# prefix 1
|
||||
if user.avatar and (user.avatar.startswith(prefix1)):
|
||||
avatar_key = user.avatar
|
||||
user.avatar = (
|
||||
"/api/users/avatar/" + avatar_key[len(prefix1) :] + "/"
|
||||
)
|
||||
bulk_users.append(user)
|
||||
|
||||
# prefix 2
|
||||
if (
|
||||
not settings.USE_MINIO
|
||||
and user.avatar
|
||||
and user.avatar.startswith(prefix2)
|
||||
):
|
||||
avatar_key = user.avatar
|
||||
user.avatar = (
|
||||
"/api/users/avatar/" + avatar_key[len(prefix2) :] + "/"
|
||||
)
|
||||
bulk_users.append(user)
|
||||
|
||||
# prefix 1
|
||||
if user.cover_image and (user.cover_image.startswith(prefix1)):
|
||||
cover_image_key = user.cover_image
|
||||
user.cover_image = (
|
||||
"/api/users/cover-image/"
|
||||
+ cover_image_key[len(prefix1) :]
|
||||
+ "/"
|
||||
)
|
||||
bulk_users.append(user)
|
||||
|
||||
# prefix 2
|
||||
if (
|
||||
not settings.USE_MINIO
|
||||
and user.cover_image
|
||||
and user.cover_image.startswith(prefix2)
|
||||
):
|
||||
cover_image_key = user.cover_image
|
||||
user.cover_image = (
|
||||
"/api/users/cover-image/"
|
||||
+ cover_image_key[len(prefix2) :]
|
||||
+ "/"
|
||||
)
|
||||
bulk_users.append(user)
|
||||
|
||||
User.objects.bulk_update(
|
||||
bulk_users, ["avatar", "cover_image"], batch_size=100
|
||||
)
|
||||
|
||||
|
||||
def update_workspace_urls(apps, schema_editor):
|
||||
# Check if the app is using minio or s3
|
||||
if settings.USE_MINIO:
|
||||
prefix1 = (
|
||||
f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/"
|
||||
)
|
||||
prefix2 = prefix1
|
||||
else:
|
||||
prefix1 = f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/"
|
||||
prefix2 = (
|
||||
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com/"
|
||||
)
|
||||
|
||||
Workspace = apps.get_model("db", "Workspace")
|
||||
bulk_workspaces = []
|
||||
|
||||
# Loop through all the users and update the cover image
|
||||
for workspace in Workspace.objects.all():
|
||||
# prefix 1
|
||||
if workspace.logo and (workspace.logo.startswith(prefix1)):
|
||||
logo_key = workspace.logo
|
||||
workspace.logo = f"/api/workspaces/{workspace.slug}/logo/{logo_key[len(prefix1) :]}/"
|
||||
bulk_workspaces.append(workspace)
|
||||
|
||||
# prefix 2
|
||||
if (
|
||||
not settings.USE_MINIO
|
||||
and workspace.logo
|
||||
and (workspace.logo.startswith(prefix2))
|
||||
):
|
||||
logo_key = workspace.logo
|
||||
workspace.logo = f"/api/workspaces/{workspace.slug}/logo/{logo_key[len(prefix2) :]}/"
|
||||
bulk_workspaces.append(workspace)
|
||||
|
||||
Workspace.objects.bulk_update(bulk_workspaces, ["logo"], batch_size=100)
|
||||
|
||||
|
||||
def update_project_urls(apps, schema_editor):
|
||||
file_assets = {}
|
||||
|
||||
# Check if the app is using minio or s3
|
||||
if settings.USE_MINIO:
|
||||
prefix1 = (
|
||||
f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/"
|
||||
)
|
||||
prefix2 = prefix1
|
||||
else:
|
||||
prefix1 = f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/"
|
||||
prefix2 = (
|
||||
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com/"
|
||||
)
|
||||
|
||||
Project = apps.get_model("db", "Project")
|
||||
bulk_projects = []
|
||||
|
||||
# Loop through all the users and update the cover image
|
||||
for project in Project.objects.all():
|
||||
# prefix 1
|
||||
if project.cover_image and (project.cover_image.startswith(prefix1)):
|
||||
cover_image_key = project.cover_image
|
||||
project.cover_image = f"/api/workspaces/{project.workspace.slug}/projects/{project.id}/cover-image/{cover_image_key[len(prefix1) :]}/"
|
||||
file_assets[cover_image_key[len(prefix1) :]] = str(project.id)
|
||||
bulk_projects.append(project)
|
||||
|
||||
# prefix 2
|
||||
if (
|
||||
not settings.USE_MINIO
|
||||
and project.cover_image
|
||||
and (project.cover_image.startswith(prefix2))
|
||||
):
|
||||
cover_image_key = project.cover_image
|
||||
project.cover_image = f"/api/workspaces/{project.workspace.slug}/projects/{project.id}/cover-image/{cover_image_key[len(prefix2) :]}/"
|
||||
file_assets[cover_image_key[len(prefix2) :]] = str(project.id)
|
||||
bulk_projects.append(project)
|
||||
|
||||
Project.objects.bulk_update(bulk_projects, ["cover_image"], batch_size=100)
|
||||
|
||||
FileAsset = apps.get_model("db", "FileAsset")
|
||||
bulk_assets = []
|
||||
for asset in FileAsset.objects.filter(asset__in=file_assets.keys()):
|
||||
asset.project_id = file_assets[str(asset.asset)]
|
||||
bulk_assets.append(asset)
|
||||
|
||||
FileAsset.objects.bulk_update(
|
||||
bulk_assets,
|
||||
[
|
||||
"project_id",
|
||||
],
|
||||
batch_size=100,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("db", "0058_alter_moduleissue_issue_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="fileasset",
|
||||
name="asset",
|
||||
field=models.FileField(
|
||||
storage=plane.settings.storage.S3PrivateBucketStorage(),
|
||||
upload_to=plane.db.models.asset.get_upload_path,
|
||||
validators=[plane.db.models.asset.file_size],
|
||||
),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="issueactivity",
|
||||
name="attachments",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="issuecomment",
|
||||
name="attachments",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="integration",
|
||||
name="avatar_url",
|
||||
field=models.CharField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="project",
|
||||
name="cover_image",
|
||||
field=models.CharField(blank=True, max_length=800, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="cover_image",
|
||||
field=models.CharField(blank=True, max_length=800, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="workspace",
|
||||
name="logo",
|
||||
field=models.CharField(blank=True, null=True, verbose_name="Logo"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="fileasset",
|
||||
name="size",
|
||||
field=models.PositiveBigIntegerField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="fileasset",
|
||||
name="entity_identifier",
|
||||
field=models.UUIDField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="fileasset",
|
||||
name="entity_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("issue_attachment", "Issue Attachment"),
|
||||
("issue_description", "Issue Description"),
|
||||
("comment", "Comment"),
|
||||
("page", "Page"),
|
||||
],
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="fileasset",
|
||||
name="project",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="assets",
|
||||
to="db.project",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(update_user_urls),
|
||||
migrations.RunPython(update_workspace_urls),
|
||||
migrations.RunPython(update_project_urls),
|
||||
]
|
||||
237
apiserver/plane/db/migrations/0060_fileasset_size.py
Normal file
237
apiserver/plane/db/migrations/0060_fileasset_size.py
Normal file
@@ -0,0 +1,237 @@
|
||||
# Generated by Django 4.2.7 on 2024-02-02 07:23
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.conf import settings
|
||||
import django.db.models.deletion
|
||||
|
||||
# Third party imports
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
def convert_issue_description_image_sources(apps, schema_editor):
|
||||
|
||||
file_assets = {}
|
||||
|
||||
if settings.USE_MINIO:
|
||||
prefix1 = (
|
||||
f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/"
|
||||
)
|
||||
prefix2 = prefix1
|
||||
else:
|
||||
prefix1 = f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/"
|
||||
prefix2 = (
|
||||
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com/"
|
||||
)
|
||||
|
||||
Issue = apps.get_model("db", "Issue")
|
||||
|
||||
bulk_issues = []
|
||||
|
||||
for issue in Issue.objects.all():
|
||||
# Parse the html
|
||||
soup = BeautifulSoup(issue.description_html, "lxml")
|
||||
img_tags = soup.find_all("img")
|
||||
for img in img_tags:
|
||||
src = img.get("src", "")
|
||||
if src and (src.startswith(prefix1)):
|
||||
img["src"] = (
|
||||
f"/api/workspaces/{issue.workspace.slug}/projects/{issue.project_id}/issues/{issue.id}/attachments/{src[len(prefix1): ]}"
|
||||
)
|
||||
file_assets[src[len(prefix1) :]] = {
|
||||
"project_id": str(issue.project_id),
|
||||
"issue_id": str(issue.id),
|
||||
}
|
||||
issue.description_html = str(soup)
|
||||
bulk_issues.append(issue)
|
||||
|
||||
# prefix 2
|
||||
if not settings.USE_MINIO and src and src.startswith(prefix2):
|
||||
img["src"] = (
|
||||
f"/api/workspaces/{issue.workspace.slug}/projects/{issue.project_id}/issues/{issue.id}/attachments/{src[len(prefix2): ]}"
|
||||
)
|
||||
file_assets[src[len(prefix2) :]] = {
|
||||
"project_id": str(issue.project_id),
|
||||
"issue_id": str(issue.id),
|
||||
}
|
||||
issue.description_html = str(soup)
|
||||
bulk_issues.append(issue)
|
||||
|
||||
# Update the issue description htmls
|
||||
Issue.objects.bulk_update(
|
||||
bulk_issues, ["description_html"], batch_size=1000
|
||||
)
|
||||
|
||||
# Update file assets
|
||||
FileAsset = apps.get_model("db", "FileAsset")
|
||||
bulk_assets = []
|
||||
for asset in FileAsset.objects.filter(asset__in=file_assets.keys()):
|
||||
asset.project_id = file_assets[str(asset.asset)]["project_id"]
|
||||
asset.entity_identifier = file_assets[str(asset.asset)]["issue_id"]
|
||||
asset.entity_type = "issue_description"
|
||||
bulk_assets.append(asset)
|
||||
|
||||
FileAsset.objects.bulk_update(
|
||||
bulk_assets,
|
||||
[
|
||||
"project_id",
|
||||
"entity_identifier",
|
||||
"entity_type",
|
||||
],
|
||||
batch_size=100,
|
||||
)
|
||||
return
|
||||
|
||||
def convert_page_image_sources(apps, schema_editor):
|
||||
|
||||
file_assets = {}
|
||||
|
||||
if settings.USE_MINIO:
|
||||
prefix1 = (
|
||||
f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/"
|
||||
)
|
||||
prefix2 = prefix1
|
||||
else:
|
||||
prefix1 = f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/"
|
||||
prefix2 = (
|
||||
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com/"
|
||||
)
|
||||
|
||||
Page = apps.get_model("db", "Page")
|
||||
FileAsset = apps.get_model("db", "FileAsset")
|
||||
|
||||
bulk_pages = []
|
||||
bulk_assets = {}
|
||||
|
||||
for page in Page.objects.all():
|
||||
# Parse the html
|
||||
soup = BeautifulSoup(page.description_html, "lxml")
|
||||
img_tags = soup.find_all("img")
|
||||
for img in img_tags:
|
||||
src = img.get("src", "")
|
||||
if src and (src.startswith(prefix1)):
|
||||
img["src"] = (
|
||||
f"/api/workspaces/{page.workspace.slug}/projects/{page.project_id}/issues/{page.id}/attachments/{src[len(prefix1): ]}/"
|
||||
)
|
||||
file_assets[src[len(prefix1) :]] = {
|
||||
"project_id": str(page.project_id),
|
||||
"page_id": str(page.id),
|
||||
}
|
||||
page.description_html = str(soup)
|
||||
bulk_pages.append(page)
|
||||
|
||||
# prefix 2
|
||||
if not settings.USE_MINIO and src and src.startswith(prefix2):
|
||||
img["src"] = (
|
||||
f"/api/workspaces/{page.workspace.slug}/projects/{page.project_id}/issues/{page.id}/attachments/{src[len(prefix2): ]}/"
|
||||
)
|
||||
file_assets[src[len(prefix2) :]] = {
|
||||
"project_id": str(page.project_id),
|
||||
"page_id": str(page.id),
|
||||
}
|
||||
page.description_html = str(soup)
|
||||
bulk_pages.append(page)
|
||||
|
||||
Page.objects.bulk_update(bulk_pages, ["description_html"], batch_size=1000)
|
||||
|
||||
# Update file assets
|
||||
FileAsset = apps.get_model("db", "FileAsset")
|
||||
bulk_assets = []
|
||||
for asset in FileAsset.objects.filter(asset__in=file_assets.keys()):
|
||||
asset.project_id = file_assets[str(asset.asset)]["project_id"]
|
||||
asset.entity_identifier = file_assets[str(asset.asset)]["page_id"]
|
||||
asset.entity_type = "page"
|
||||
bulk_assets.append(asset)
|
||||
|
||||
FileAsset.objects.bulk_update(
|
||||
bulk_assets,
|
||||
[
|
||||
"project_id",
|
||||
"entity_identifier",
|
||||
"entity_type",
|
||||
],
|
||||
batch_size=100,
|
||||
)
|
||||
return
|
||||
|
||||
def convert_comment_image_sources(apps, schema_editor):
|
||||
|
||||
file_assets = {}
|
||||
|
||||
if settings.USE_MINIO:
|
||||
prefix1 = (
|
||||
f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/"
|
||||
)
|
||||
prefix2 = prefix1
|
||||
else:
|
||||
prefix1 = f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/"
|
||||
prefix2 = (
|
||||
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com/"
|
||||
)
|
||||
|
||||
IssueComment = apps.get_model("db", "IssueComment")
|
||||
|
||||
bulk_comments = []
|
||||
bulk_assets = {}
|
||||
|
||||
for comment in IssueComment.objects.all():
|
||||
# Parse the html
|
||||
soup = BeautifulSoup(comment.comment_html, "lxml")
|
||||
img_tags = soup.find_all("img")
|
||||
for img in img_tags:
|
||||
src = img.get("src", "")
|
||||
if src and (src.startswith(prefix1)):
|
||||
img["src"] = (
|
||||
f"/api/workspaces/{comment.workspace.slug}/projects/{comment.project_id}/issues/{comment.id}/attachments/{src[len(prefix1): ]}/"
|
||||
)
|
||||
file_assets[src[len(prefix1) :]] = {
|
||||
"project_id": str(comment.project_id),
|
||||
"comment_id": str(comment.id),
|
||||
}
|
||||
comment.comment_html = str(soup)
|
||||
bulk_comments.append(comment)
|
||||
|
||||
# prefix 2
|
||||
if not settings.USE_MINIO and src and src.startswith(prefix2):
|
||||
img["src"] = (
|
||||
f"/api/workspaces/{comment.workspace.slug}/projects/{comment.project_id}/issues/{comment.id}/attachments/{src[len(prefix2): ]}/"
|
||||
)
|
||||
file_assets[src[len(prefix2) :]] = {
|
||||
"project_id": str(comment.project_id),
|
||||
"comment_id": str(comment.id),
|
||||
}
|
||||
comment.comment_html = str(soup)
|
||||
bulk_comments.append(comment)
|
||||
|
||||
IssueComment.objects.bulk_update(bulk_comments, ["comment_html"], batch_size=1000)
|
||||
|
||||
# Update file assets
|
||||
FileAsset = apps.get_model("db", "FileAsset")
|
||||
bulk_assets = []
|
||||
for asset in FileAsset.objects.filter(asset__in=file_assets.keys()):
|
||||
asset.project_id = file_assets[str(asset.asset)]["project_id"]
|
||||
asset.entity_identifier = file_assets[str(asset.asset)]["comment_id"]
|
||||
asset.entity_type = "comment"
|
||||
bulk_assets.append(asset)
|
||||
|
||||
FileAsset.objects.bulk_update(
|
||||
bulk_assets,
|
||||
[
|
||||
"project_id",
|
||||
"entity_identifier",
|
||||
"entity_type",
|
||||
],
|
||||
batch_size=100,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("db", "0059_auto_20240131_1334"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(convert_issue_description_image_sources),
|
||||
migrations.RunPython(convert_page_image_sources),
|
||||
migrations.RunPython(convert_comment_image_sources),
|
||||
]
|
||||
@@ -0,0 +1,44 @@
|
||||
# Generated by Django 4.2.7 on 2024-02-05 06:42
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def create_attachment_assets(apps, schema_editor):
|
||||
bulk_assets = []
|
||||
|
||||
issue_attachments = {}
|
||||
|
||||
FileAsset = apps.get_model("db", "FileAsset")
|
||||
IssueAttachment = apps.get_model("db", "IssueAttachment")
|
||||
|
||||
for issue_attachment in IssueAttachment.objects.values():
|
||||
bulk_assets.append(
|
||||
FileAsset(
|
||||
workspace_id=issue_attachment["workspace_id"],
|
||||
project_id=issue_attachment["project_id"],
|
||||
entity_identifier=issue_attachment["issue_id"],
|
||||
entity_type="issue_attachment",
|
||||
asset=issue_attachment["asset"],
|
||||
attributes=issue_attachment["attributes"],
|
||||
)
|
||||
)
|
||||
issue_attachments[str(issue_attachment["asset"])] = str(
|
||||
issue_attachment["id"]
|
||||
)
|
||||
|
||||
FileAsset.objects.bulk_create(bulk_assets, batch_size=100)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("db", "0060_fileasset_size"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_attachment_assets),
|
||||
migrations.DeleteModel(
|
||||
name="IssueAttachment",
|
||||
),
|
||||
]
|
||||
@@ -37,7 +37,6 @@ from .issue import (
|
||||
IssueMention,
|
||||
IssueLink,
|
||||
IssueSequence,
|
||||
IssueAttachment,
|
||||
IssueSubscriber,
|
||||
IssueReaction,
|
||||
CommentReaction,
|
||||
|
||||
@@ -8,12 +8,12 @@ from django.conf import settings
|
||||
|
||||
# Module import
|
||||
from . import BaseModel
|
||||
|
||||
from plane.settings.storage import S3PrivateBucketStorage
|
||||
|
||||
def get_upload_path(instance, filename):
|
||||
if instance.workspace_id is not None:
|
||||
return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
|
||||
return f"user-{uuid4().hex}-{filename}"
|
||||
return f"{instance.workspace.id}/{uuid4().hex}"
|
||||
return f"user-{uuid4().hex}"
|
||||
|
||||
|
||||
def file_size(value):
|
||||
@@ -32,6 +32,7 @@ class FileAsset(BaseModel):
|
||||
validators=[
|
||||
file_size,
|
||||
],
|
||||
storage=S3PrivateBucketStorage(),
|
||||
)
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace",
|
||||
@@ -39,7 +40,24 @@ class FileAsset(BaseModel):
|
||||
null=True,
|
||||
related_name="assets",
|
||||
)
|
||||
project = models.ForeignKey(
|
||||
"db.Project",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
related_name="assets",
|
||||
)
|
||||
entity_type = models.CharField(
|
||||
choices=(
|
||||
("issue_attachment", "Issue Attachment"),
|
||||
("issue_description", "Issue Description"),
|
||||
("comment", "Comment"),
|
||||
("page", "Page"),
|
||||
),
|
||||
null=True,
|
||||
)
|
||||
entity_identifier = models.UUIDField(null=True)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
size = models.PositiveBigIntegerField(null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "File Asset"
|
||||
@@ -49,3 +67,7 @@ class FileAsset(BaseModel):
|
||||
|
||||
def __str__(self):
|
||||
return str(self.asset)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.size = self.asset.size
|
||||
super(FileAsset, self).save(*args, **kwargs)
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -337,7 +337,7 @@ class IssueLink(ProjectBaseModel):
|
||||
|
||||
|
||||
def get_upload_path(instance, filename):
|
||||
return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
|
||||
return f"{instance.workspace.id}/{uuid4().hex}"
|
||||
|
||||
|
||||
def file_size(value):
|
||||
@@ -345,29 +345,6 @@ def file_size(value):
|
||||
if value.size > settings.FILE_SIZE_LIMIT:
|
||||
raise ValidationError("File too large. Size should not exceed 5 MB.")
|
||||
|
||||
|
||||
class IssueAttachment(ProjectBaseModel):
|
||||
attributes = models.JSONField(default=dict)
|
||||
asset = models.FileField(
|
||||
upload_to=get_upload_path,
|
||||
validators=[
|
||||
file_size,
|
||||
],
|
||||
)
|
||||
issue = models.ForeignKey(
|
||||
"db.Issue", on_delete=models.CASCADE, related_name="issue_attachment"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue Attachment"
|
||||
verbose_name_plural = "Issue Attachments"
|
||||
db_table = "issue_attachments"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.issue.name} {self.asset}"
|
||||
|
||||
|
||||
class IssueActivity(ProjectBaseModel):
|
||||
issue = models.ForeignKey(
|
||||
Issue,
|
||||
@@ -389,9 +366,6 @@ class IssueActivity(ProjectBaseModel):
|
||||
)
|
||||
|
||||
comment = models.TextField(verbose_name="Comment", blank=True)
|
||||
attachments = ArrayField(
|
||||
models.URLField(), size=10, blank=True, default=list
|
||||
)
|
||||
issue_comment = models.ForeignKey(
|
||||
"db.IssueComment",
|
||||
on_delete=models.SET_NULL,
|
||||
@@ -423,9 +397,6 @@ class IssueComment(ProjectBaseModel):
|
||||
comment_stripped = models.TextField(verbose_name="Comment", blank=True)
|
||||
comment_json = models.JSONField(blank=True, default=dict)
|
||||
comment_html = models.TextField(blank=True, default="<p></p>")
|
||||
attachments = ArrayField(
|
||||
models.URLField(), size=10, blank=True, default=list
|
||||
)
|
||||
issue = models.ForeignKey(
|
||||
Issue, on_delete=models.CASCADE, related_name="issue_comments"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,12 +15,17 @@ from django.db.models.signals import post_save
|
||||
from django.conf import settings
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
# Third party imports
|
||||
from sentry_sdk import capture_exception
|
||||
from slack_sdk import WebClient
|
||||
from slack_sdk.errors import SlackApiError
|
||||
|
||||
# Module imports
|
||||
from . import BaseModel
|
||||
from plane.settings.storage import S3PrivateBucketStorage
|
||||
|
||||
|
||||
def get_default_onboarding():
|
||||
return {
|
||||
@@ -49,7 +54,7 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
first_name = models.CharField(max_length=255, blank=True)
|
||||
last_name = models.CharField(max_length=255, blank=True)
|
||||
avatar = models.CharField(max_length=255, blank=True)
|
||||
cover_image = models.URLField(blank=True, null=True, max_length=800)
|
||||
cover_image = models.CharField(blank=True, null=True, max_length=800)
|
||||
|
||||
# tracking metrics
|
||||
date_joined = models.DateTimeField(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -226,14 +226,14 @@ STORAGES = {
|
||||
},
|
||||
}
|
||||
STORAGES["default"] = {
|
||||
"BACKEND": "storages.backends.s3boto3.S3Boto3Storage",
|
||||
"BACKEND": "plane.settings.storage.S3PrivateBucketStorage",
|
||||
}
|
||||
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key")
|
||||
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key")
|
||||
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads")
|
||||
AWS_REGION = os.environ.get("AWS_REGION", "")
|
||||
AWS_DEFAULT_ACL = "public-read"
|
||||
AWS_QUERYSTRING_AUTH = False
|
||||
AWS_QUERYSTRING_AUTH = True
|
||||
AWS_S3_FILE_OVERWRITE = False
|
||||
AWS_S3_ENDPOINT_URL = os.environ.get(
|
||||
"AWS_S3_ENDPOINT_URL", None
|
||||
|
||||
9
apiserver/plane/settings/storage.py
Normal file
9
apiserver/plane/settings/storage.py
Normal 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
|
||||
@@ -22,7 +22,7 @@ from plane.db.models import (
|
||||
CycleIssue,
|
||||
ModuleIssue,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
IssueReaction,
|
||||
CommentReaction,
|
||||
IssueVote,
|
||||
@@ -171,22 +171,6 @@ class IssueLinkSerializer(BaseSerializer):
|
||||
)
|
||||
return IssueLink.objects.create(**validated_data)
|
||||
|
||||
|
||||
class IssueAttachmentSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = IssueAttachment
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"workspace",
|
||||
"project",
|
||||
"issue",
|
||||
]
|
||||
|
||||
|
||||
class IssueReactionSerializer(BaseSerializer):
|
||||
actor_detail = UserLiteSerializer(read_only=True, source="actor")
|
||||
|
||||
@@ -218,7 +202,6 @@ class IssueSerializer(BaseSerializer):
|
||||
issue_cycle = IssueCycleDetailSerializer(read_only=True)
|
||||
issue_module = IssueModuleDetailSerializer(read_only=True)
|
||||
issue_link = IssueLinkSerializer(read_only=True, many=True)
|
||||
issue_attachment = IssueAttachmentSerializer(read_only=True, many=True)
|
||||
sub_issues_count = serializers.IntegerField(read_only=True)
|
||||
issue_reactions = IssueReactionSerializer(read_only=True, many=True)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from plane.db.models import (
|
||||
Issue,
|
||||
State,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
ProjectDeployBoard,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
@@ -95,8 +95,9 @@ class InboxIssuePublicViewSet(BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
||||
@@ -40,7 +40,7 @@ from plane.db.models import (
|
||||
IssueComment,
|
||||
Label,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
State,
|
||||
ProjectMember,
|
||||
IssueReaction,
|
||||
@@ -567,8 +567,9 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
||||
59
apiserver/plane/utils/file_stream.py
Normal file
59
apiserver/plane/utils/file_stream.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
import requests
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.http import StreamingHttpResponse
|
||||
# Third party imports
|
||||
import boto3
|
||||
|
||||
|
||||
def get_file_streams(key, filename=uuid.uuid4().hex):
|
||||
|
||||
if settings.USE_MINIO:
|
||||
s3 = boto3.client(
|
||||
's3',
|
||||
endpoint_url=settings.AWS_S3_ENDPOINT_URL,
|
||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
config=boto3.session.Config(signature_version='s3v4'),
|
||||
)
|
||||
else:
|
||||
s3 = boto3.client(
|
||||
's3',
|
||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
config=boto3.session.Config(signature_version='s3v4'),
|
||||
)
|
||||
|
||||
presigned_url = s3.generate_presigned_url(
|
||||
'get_object',
|
||||
Params={
|
||||
'Bucket': settings.AWS_STORAGE_BUCKET_NAME,
|
||||
'Key': key,
|
||||
},
|
||||
ExpiresIn=3600,
|
||||
)
|
||||
|
||||
# Fetch the object metadata to get the content type
|
||||
metadata = s3.head_object(
|
||||
Bucket=settings.AWS_STORAGE_BUCKET_NAME,
|
||||
Key=key,
|
||||
)
|
||||
|
||||
|
||||
# Stream the file from the custom endpoint URL
|
||||
def stream_file_from_url(url):
|
||||
with requests.get(url, stream=True) as r:
|
||||
r.raise_for_status()
|
||||
for chunk in r.iter_content(chunk_size=8192):
|
||||
if chunk: # filter out keep-alive new chunks
|
||||
yield chunk
|
||||
|
||||
|
||||
content_type = metadata['ContentType']
|
||||
response = StreamingHttpResponse(stream_file_from_url(presigned_url), content_type=content_type)
|
||||
response['Content-Disposition'] = f'inline; filename={filename}' # Adjust filename as needed
|
||||
|
||||
return response
|
||||
63
apiserver/plane/utils/presigned_url_generator.py
Normal file
63
apiserver/plane/utils/presigned_url_generator.py
Normal 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
|
||||
@@ -13,7 +13,8 @@ SENTRY_ENVIRONMENT=production
|
||||
GOOGLE_CLIENT_ID=
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
DOCKERIZED=1 # deprecated
|
||||
# deprecated
|
||||
DOCKERIZED=1
|
||||
CORS_ALLOWED_ORIGINS=http://localhost
|
||||
|
||||
#DB SETTINGS
|
||||
@@ -23,12 +24,12 @@ POSTGRES_USER=plane
|
||||
POSTGRES_PASSWORD=plane
|
||||
POSTGRES_DB=plane
|
||||
PGDATA=/var/lib/postgresql/data
|
||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${PGHOST}/${PGDATABASE}
|
||||
DATABASE_URL=
|
||||
|
||||
# REDIS SETTINGS
|
||||
REDIS_HOST=plane-redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_URL=redis://${REDIS_HOST}:6379/
|
||||
REDIS_URL=
|
||||
|
||||
# EMAIL SETTINGS
|
||||
EMAIL_HOST=
|
||||
@@ -39,6 +40,14 @@ EMAIL_FROM=Team Plane <team@mailer.plane.so>
|
||||
EMAIL_USE_TLS=1
|
||||
EMAIL_USE_SSL=0
|
||||
|
||||
# OPENAI SETTINGS
|
||||
# deprecated
|
||||
OPENAI_API_BASE=https://api.openai.com/v1
|
||||
# deprecated
|
||||
OPENAI_API_KEY=sk-
|
||||
# deprecated
|
||||
GPT_ENGINE=gpt-3.5-turbo
|
||||
|
||||
# LOGIN/SIGNUP SETTINGS
|
||||
ENABLE_SIGNUP=1
|
||||
ENABLE_EMAIL_PASSWORD=1
|
||||
|
||||
@@ -16,6 +16,12 @@ http {
|
||||
add_header Permissions-Policy "interest-cohort=()" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
add_header Host ${dollar}host;
|
||||
add_header X-Real-IP ${dollar}remote_addr;
|
||||
add_header X-Forwarded-For ${dollar}proxy_add_x_forwarded_for;
|
||||
add_header X-Forwarded-Proto ${dollar}scheme;
|
||||
|
||||
|
||||
location / {
|
||||
proxy_pass http://web:3000/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -16,6 +16,11 @@ http {
|
||||
add_header Permissions-Policy "interest-cohort=()" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
add_header Host ${dollar}host;
|
||||
add_header X-Real-IP ${dollar}remote_addr;
|
||||
add_header X-Forwarded-For ${dollar}proxy_add_x_forwarded_for;
|
||||
add_header X-Forwarded-Proto ${dollar}scheme;
|
||||
|
||||
location / {
|
||||
proxy_pass http://web:3000/;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ interface CustomEditorProps {
|
||||
description_html: string;
|
||||
};
|
||||
deleteFile: DeleteImage;
|
||||
getAsset: any;
|
||||
cancelUploadImage?: () => any;
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||
setShouldShowAlert?: (showAlert: boolean) => void;
|
||||
@@ -35,6 +36,7 @@ export const useEditor = ({
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
cancelUploadImage,
|
||||
getAsset,
|
||||
editorProps = {},
|
||||
value,
|
||||
rerenderOnPropsChange,
|
||||
@@ -62,7 +64,8 @@ export const useEditor = ({
|
||||
},
|
||||
deleteFile,
|
||||
restoreFile,
|
||||
cancelUploadImage
|
||||
cancelUploadImage,
|
||||
getAsset
|
||||
),
|
||||
...extensions,
|
||||
],
|
||||
@@ -80,6 +83,7 @@ export const useEditor = ({
|
||||
[rerenderOnPropsChange]
|
||||
);
|
||||
|
||||
console.log("yoooooooooo", editor?.getHTML());
|
||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||
editorRef.current = editor;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -11,7 +11,7 @@ interface EditorContentProps {
|
||||
export const EditorContentWrapper = ({ editor, editorContentCustomClassNames = "", children }: EditorContentProps) => (
|
||||
<div className={`contentEditor ${editorContentCustomClassNames}`}>
|
||||
<EditorContent editor={editor} />
|
||||
{editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} />}
|
||||
{/* {editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} />} */}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
347
packages/editor/core/src/ui/extensions/image/image.tsx
Normal file
347
packages/editor/core/src/ui/extensions/image/image.tsx
Normal 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 });
|
||||
@@ -11,7 +11,7 @@ import { TableCell } from "src/ui/extensions/table/table-cell/table-cell";
|
||||
import { TableHeader } from "src/ui/extensions/table/table-header/table-header";
|
||||
import { TableRow } from "src/ui/extensions/table/table-row/table-row";
|
||||
|
||||
import { ImageExtension } from "src/ui/extensions/image";
|
||||
import { ImageExtension } from "src/ui/extensions/image/image";
|
||||
|
||||
import { isValidHttpUrl } from "src/lib/utils";
|
||||
import { Mentions } from "src/ui/mentions";
|
||||
@@ -34,7 +34,8 @@ export const CoreEditorExtensions = (
|
||||
},
|
||||
deleteFile: DeleteImage,
|
||||
restoreFile: RestoreImage,
|
||||
cancelUploadImage?: () => any
|
||||
cancelUploadImage?: () => any,
|
||||
getAsset?: any
|
||||
) => [
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
@@ -79,7 +80,7 @@ export const CoreEditorExtensions = (
|
||||
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
||||
},
|
||||
}),
|
||||
ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({
|
||||
ImageExtension(deleteFile, restoreFile, cancelUploadImage, getAsset).configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-lg border border-custom-border-300",
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -26,6 +26,7 @@ export type IRichTextEditor = {
|
||||
id: string;
|
||||
description_html: string;
|
||||
};
|
||||
getAsset: any;
|
||||
customClassName?: string;
|
||||
editorContentCustomClassNames?: string;
|
||||
onChange?: (json: any, html: string) => void;
|
||||
@@ -55,6 +56,7 @@ const RichTextEditor = ({
|
||||
editorContentCustomClassNames,
|
||||
value,
|
||||
uploadFile,
|
||||
getAsset,
|
||||
deleteFile,
|
||||
noBorder,
|
||||
cancelUploadImage,
|
||||
@@ -76,6 +78,7 @@ const RichTextEditor = ({
|
||||
|
||||
const editor = useEditor({
|
||||
onChange,
|
||||
getAsset,
|
||||
debouncedUpdatesEnabled,
|
||||
setIsSubmitting,
|
||||
setShouldShowAlert,
|
||||
|
||||
@@ -174,9 +174,10 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
|
||||
!disabled ? (
|
||||
<RichTextEditor
|
||||
cancelUploadImage={fileService.cancelUpload}
|
||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
|
||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug, projectId, issueId)}
|
||||
deleteFile={fileService.getDeleteImageFunction(workspaceId)}
|
||||
restoreFile={fileService.getRestoreImageFunction(workspaceId)}
|
||||
getAsset={fileService.getAttachAssetToIssueFile(workspaceSlug, projectId, issueId)}
|
||||
value={localIssueDescription.description_html}
|
||||
rerenderOnPropsChange={localIssueDescription}
|
||||
setShouldShowAlert={setShowAlert}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user