mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
Compare commits
28 Commits
feat/confl
...
update-fil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08ec379e9c | ||
|
|
d560d1f7df | ||
|
|
6a6d0d881c | ||
|
|
6cc29dca52 | ||
|
|
14c6cc1420 | ||
|
|
9bc144649a | ||
|
|
6037321572 | ||
|
|
4165111ce1 | ||
|
|
4e88854d46 | ||
|
|
6719738a9f | ||
|
|
79685b33d6 | ||
|
|
0f0e86fdb4 | ||
|
|
9e310994cd | ||
|
|
c25ac3b3a6 | ||
|
|
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,
|
||||
)
|
||||
|
||||
@@ -13,7 +13,6 @@ from plane.db.models import (
|
||||
Issue,
|
||||
IssueActivity,
|
||||
IssueAssignee,
|
||||
IssueAttachment,
|
||||
IssueComment,
|
||||
IssueLabel,
|
||||
IssueLink,
|
||||
@@ -79,7 +78,7 @@ class IssueSerializer(BaseSerializer):
|
||||
parsed_str = html.tostring(parsed, encoding="unicode")
|
||||
data["description_html"] = parsed_str
|
||||
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
raise serializers.ValidationError("Invalid HTML passed")
|
||||
|
||||
# Validate assignees are from project
|
||||
@@ -323,22 +322,6 @@ class IssueLinkSerializer(BaseSerializer):
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class IssueAttachmentSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = IssueAttachment
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"workspace",
|
||||
"project",
|
||||
"issue",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class IssueCommentSerializer(BaseSerializer):
|
||||
is_member = serializers.BooleanField(read_only=True)
|
||||
|
||||
@@ -366,7 +349,7 @@ class IssueCommentSerializer(BaseSerializer):
|
||||
parsed_str = html.tostring(parsed, encoding="unicode")
|
||||
data["comment_html"] = parsed_str
|
||||
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
raise serializers.ValidationError("Invalid HTML passed")
|
||||
return data
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from plane.db.models import (
|
||||
Issue,
|
||||
CycleIssue,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
)
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.api.serializers import (
|
||||
@@ -580,8 +580,9 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
# Django imports
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import (
|
||||
Case,
|
||||
@@ -23,7 +22,6 @@ from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.api.serializers import (
|
||||
IssueActivitySerializer,
|
||||
IssueCommentSerializer,
|
||||
@@ -38,9 +36,9 @@ from plane.app.permissions import (
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.db.models import (
|
||||
FileAsset,
|
||||
Issue,
|
||||
IssueActivity,
|
||||
IssueAttachment,
|
||||
IssueComment,
|
||||
IssueLink,
|
||||
Label,
|
||||
@@ -48,6 +46,7 @@ from plane.db.models import (
|
||||
ProjectMember,
|
||||
)
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView, WebhookMixin
|
||||
|
||||
|
||||
@@ -128,8 +127,9 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
||||
@@ -19,8 +19,8 @@ from plane.db.models import (
|
||||
ModuleLink,
|
||||
Issue,
|
||||
ModuleIssue,
|
||||
IssueAttachment,
|
||||
IssueLink,
|
||||
FileAsset,
|
||||
)
|
||||
from plane.api.serializers import (
|
||||
ModuleSerializer,
|
||||
@@ -332,8 +332,9 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
||||
@@ -60,7 +60,6 @@ from .issue import (
|
||||
IssueStateSerializer,
|
||||
IssueLinkSerializer,
|
||||
IssueLiteSerializer,
|
||||
IssueAttachmentSerializer,
|
||||
IssueSubscriberSerializer,
|
||||
IssueReactionSerializer,
|
||||
CommentReactionSerializer,
|
||||
@@ -70,7 +69,6 @@ from .issue import (
|
||||
IssuePublicSerializer,
|
||||
IssueDetailSerializer,
|
||||
IssueReactionLiteSerializer,
|
||||
IssueAttachmentLiteSerializer,
|
||||
IssueLinkLiteSerializer,
|
||||
)
|
||||
|
||||
|
||||
@@ -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__"
|
||||
|
||||
@@ -51,19 +51,18 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||
for field in allowed:
|
||||
if field not in self.fields:
|
||||
from . import (
|
||||
WorkspaceLiteSerializer,
|
||||
ProjectLiteSerializer,
|
||||
UserLiteSerializer,
|
||||
StateLiteSerializer,
|
||||
CycleIssueSerializer,
|
||||
InboxIssueLiteSerializer,
|
||||
IssueLinkLiteSerializer,
|
||||
IssueLiteSerializer,
|
||||
IssueReactionLiteSerializer,
|
||||
IssueRelationSerializer,
|
||||
IssueSerializer,
|
||||
LabelSerializer,
|
||||
CycleIssueSerializer,
|
||||
IssueLiteSerializer,
|
||||
IssueRelationSerializer,
|
||||
InboxIssueLiteSerializer,
|
||||
IssueReactionLiteSerializer,
|
||||
IssueAttachmentLiteSerializer,
|
||||
IssueLinkLiteSerializer,
|
||||
ProjectLiteSerializer,
|
||||
StateLiteSerializer,
|
||||
UserLiteSerializer,
|
||||
WorkspaceLiteSerializer,
|
||||
)
|
||||
|
||||
# Expansion mapper
|
||||
@@ -86,7 +85,6 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||
"issue_relation": IssueRelationSerializer,
|
||||
"issue_inbox": InboxIssueLiteSerializer,
|
||||
"issue_reactions": IssueReactionLiteSerializer,
|
||||
"issue_attachment": IssueAttachmentLiteSerializer,
|
||||
"issue_link": IssueLinkLiteSerializer,
|
||||
"sub_issues": IssueLiteSerializer,
|
||||
}
|
||||
@@ -122,19 +120,18 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||
if expand in self.fields:
|
||||
# Import all the expandable serializers
|
||||
from . import (
|
||||
WorkspaceLiteSerializer,
|
||||
ProjectLiteSerializer,
|
||||
UserLiteSerializer,
|
||||
StateLiteSerializer,
|
||||
IssueSerializer,
|
||||
LabelSerializer,
|
||||
CycleIssueSerializer,
|
||||
IssueRelationSerializer,
|
||||
InboxIssueLiteSerializer,
|
||||
IssueLinkLiteSerializer,
|
||||
IssueLiteSerializer,
|
||||
IssueReactionLiteSerializer,
|
||||
IssueAttachmentLiteSerializer,
|
||||
IssueLinkLiteSerializer,
|
||||
IssueRelationSerializer,
|
||||
IssueSerializer,
|
||||
LabelSerializer,
|
||||
ProjectLiteSerializer,
|
||||
StateLiteSerializer,
|
||||
UserLiteSerializer,
|
||||
WorkspaceLiteSerializer,
|
||||
)
|
||||
|
||||
# Expansion mapper
|
||||
@@ -157,7 +154,6 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||
"issue_relation": IssueRelationSerializer,
|
||||
"issue_inbox": InboxIssueLiteSerializer,
|
||||
"issue_reactions": IssueReactionLiteSerializer,
|
||||
"issue_attachment": IssueAttachmentLiteSerializer,
|
||||
"issue_link": IssueLinkLiteSerializer,
|
||||
"sub_issues": IssueLiteSerializer,
|
||||
}
|
||||
@@ -179,3 +175,20 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class BaseFileSerializer(DynamicBaseSerializer):
|
||||
|
||||
class Meta:
|
||||
abstract = True # Make this serializer abstract
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""
|
||||
Object instance -> Dict of primitive datatypes.
|
||||
"""
|
||||
response = super().to_representation(instance)
|
||||
response["asset"] = (
|
||||
instance.asset.name
|
||||
) # Ensure 'asset' field is consistently serialized
|
||||
# Apply custom method to get download URL
|
||||
return response
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.core.validators import URLValidator
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import URLValidator
|
||||
from django.utils import timezone
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
from .base import BaseSerializer, DynamicBaseSerializer
|
||||
from .user import UserLiteSerializer
|
||||
from .state import StateLiteSerializer
|
||||
from .project import ProjectLiteSerializer
|
||||
from .workspace import WorkspaceLiteSerializer
|
||||
from plane.db.models import (
|
||||
User,
|
||||
CommentReaction,
|
||||
Cycle,
|
||||
CycleIssue,
|
||||
Issue,
|
||||
IssueActivity,
|
||||
IssueComment,
|
||||
IssueProperty,
|
||||
IssueAssignee,
|
||||
IssueSubscriber,
|
||||
IssueComment,
|
||||
IssueLabel,
|
||||
IssueLink,
|
||||
IssueProperty,
|
||||
IssueReaction,
|
||||
IssueRelation,
|
||||
IssueSubscriber,
|
||||
IssueVote,
|
||||
Label,
|
||||
CycleIssue,
|
||||
Cycle,
|
||||
Module,
|
||||
ModuleIssue,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
IssueReaction,
|
||||
CommentReaction,
|
||||
IssueVote,
|
||||
IssueRelation,
|
||||
State,
|
||||
User,
|
||||
)
|
||||
|
||||
# Module imports
|
||||
from .base import BaseSerializer, DynamicBaseSerializer
|
||||
from .project import ProjectLiteSerializer
|
||||
from .state import StateLiteSerializer
|
||||
from .user import UserLiteSerializer
|
||||
from .workspace import WorkspaceLiteSerializer
|
||||
|
||||
|
||||
class IssueFlatSerializer(BaseSerializer):
|
||||
## Contain only flat fields
|
||||
@@ -442,7 +442,7 @@ class IssueLinkSerializer(BaseSerializer):
|
||||
raise serializers.ValidationError("Invalid URL format.")
|
||||
|
||||
# Check URL scheme
|
||||
if not value.startswith(('http://', 'https://')):
|
||||
if not value.startswith(("http://", "https://")):
|
||||
raise serializers.ValidationError("Invalid URL scheme.")
|
||||
|
||||
return value
|
||||
@@ -485,35 +485,6 @@ class IssueLinkLiteSerializer(BaseSerializer):
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class IssueAttachmentSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = IssueAttachment
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"workspace",
|
||||
"project",
|
||||
"issue",
|
||||
]
|
||||
|
||||
|
||||
class IssueAttachmentLiteSerializer(DynamicBaseSerializer):
|
||||
class Meta:
|
||||
model = IssueAttachment
|
||||
fields = [
|
||||
"id",
|
||||
"asset",
|
||||
"attributes",
|
||||
"issue_id",
|
||||
"updated_at",
|
||||
"updated_by_id",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class IssueReactionSerializer(BaseSerializer):
|
||||
actor_detail = UserLiteSerializer(read_only=True, source="actor")
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -3,15 +3,15 @@ from django.urls import path
|
||||
from plane.app.views import (
|
||||
BulkCreateIssueLabelsEndpoint,
|
||||
BulkDeleteIssuesEndpoint,
|
||||
SubIssuesEndpoint,
|
||||
IssueLinkViewSet,
|
||||
IssueAttachmentEndpoint,
|
||||
CommentAssetEndpoint,
|
||||
CommentReactionViewSet,
|
||||
ExportIssuesEndpoint,
|
||||
IssueActivityEndpoint,
|
||||
IssueArchiveViewSet,
|
||||
IssueAttachmentEndpoint,
|
||||
IssueCommentViewSet,
|
||||
IssueDraftViewSet,
|
||||
IssueLinkViewSet,
|
||||
IssueListEndpoint,
|
||||
IssueReactionViewSet,
|
||||
IssueRelationViewSet,
|
||||
@@ -19,6 +19,7 @@ from plane.app.views import (
|
||||
IssueUserDisplayPropertyEndpoint,
|
||||
IssueViewSet,
|
||||
LabelViewSet,
|
||||
SubIssuesEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@@ -109,16 +110,6 @@ urlpatterns = [
|
||||
),
|
||||
name="project-issue-links",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/",
|
||||
IssueAttachmentEndpoint.as_view(),
|
||||
name="project-issue-attachments",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/<uuid:pk>/",
|
||||
IssueAttachmentEndpoint.as_view(),
|
||||
name="project-issue-attachments",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/export-issues/",
|
||||
ExportIssuesEndpoint.as_view(),
|
||||
@@ -297,5 +288,26 @@ urlpatterns = [
|
||||
}
|
||||
),
|
||||
name="project-issue-draft",
|
||||
), # Comment Assets
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/comments/<uuid:comment_id>/attachments/",
|
||||
CommentAssetEndpoint.as_view(),
|
||||
name="project-comment-attachments",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/comments/<uuid:comment_id>/attachments/<uuid:workspace_id>/<str:asset_key>/",
|
||||
CommentAssetEndpoint.as_view(),
|
||||
name="project-comment-attachments",
|
||||
),
|
||||
## End Comments
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/",
|
||||
IssueAttachmentEndpoint.as_view(),
|
||||
name="project-issue-attachments",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/<uuid:workspace_id>/<str:asset_key>/",
|
||||
IssueAttachmentEndpoint.as_view(),
|
||||
name="project-issue-attachments",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
from django.urls import path
|
||||
|
||||
from plane.app.views import (
|
||||
ProjectViewSet,
|
||||
ProjectInvitationsViewset,
|
||||
ProjectMemberViewSet,
|
||||
ProjectMemberUserEndpoint,
|
||||
ProjectJoinEndpoint,
|
||||
AddTeamToProjectEndpoint,
|
||||
ProjectUserViewsEndpoint,
|
||||
ProjectIdentifierEndpoint,
|
||||
ProjectFavoritesViewSet,
|
||||
UserProjectInvitationsViewset,
|
||||
ProjectPublicCoverImagesEndpoint,
|
||||
ProjectDeployBoardViewSet,
|
||||
UserProjectRolesEndpoint,
|
||||
ProjectArchiveUnarchiveEndpoint,
|
||||
ProjectCoverImageEndpoint,
|
||||
ProjectDeployBoardViewSet,
|
||||
ProjectFavoritesViewSet,
|
||||
ProjectIdentifierEndpoint,
|
||||
ProjectInvitationsViewset,
|
||||
ProjectJoinEndpoint,
|
||||
ProjectMemberUserEndpoint,
|
||||
ProjectMemberViewSet,
|
||||
ProjectPublicCoverImagesEndpoint,
|
||||
ProjectUserViewsEndpoint,
|
||||
ProjectViewSet,
|
||||
UserProjectInvitationsViewset,
|
||||
UserProjectRolesEndpoint,
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/",
|
||||
@@ -181,4 +181,14 @@ urlpatterns = [
|
||||
ProjectArchiveUnarchiveEndpoint.as_view(),
|
||||
name="project-archive-unarchive",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/cover-image/<str:workspace_id>/<str:cover_image_key>/",
|
||||
ProjectCoverImageEndpoint.as_view(),
|
||||
name="project-cover-image",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/cover-image/",
|
||||
ProjectCoverImageEndpoint.as_view(),
|
||||
name="project-cover-image",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -1,33 +1,32 @@
|
||||
from django.urls import path
|
||||
|
||||
|
||||
from plane.app.views import (
|
||||
UserWorkspaceInvitationsViewSet,
|
||||
WorkSpaceViewSet,
|
||||
WorkspaceJoinEndpoint,
|
||||
WorkSpaceMemberViewSet,
|
||||
WorkspaceInvitationsViewset,
|
||||
WorkspaceMemberUserEndpoint,
|
||||
WorkspaceMemberUserViewsEndpoint,
|
||||
WorkSpaceAvailabilityCheckEndpoint,
|
||||
ExportWorkspaceUserActivityEndpoint,
|
||||
TeamMemberViewSet,
|
||||
UserLastProjectWithWorkspaceEndpoint,
|
||||
UserWorkspaceInvitationsViewSet,
|
||||
WorkSpaceAvailabilityCheckEndpoint,
|
||||
WorkspaceCyclesEndpoint,
|
||||
WorkspaceEstimatesEndpoint,
|
||||
WorkspaceInvitationsViewset,
|
||||
WorkspaceJoinEndpoint,
|
||||
WorkspaceLabelsEndpoint,
|
||||
WorkspaceLogoEndpoint,
|
||||
WorkspaceMemberUserEndpoint,
|
||||
WorkspaceMemberUserViewsEndpoint,
|
||||
WorkSpaceMemberViewSet,
|
||||
WorkspaceModulesEndpoint,
|
||||
WorkspaceProjectMemberEndpoint,
|
||||
WorkspaceStatesEndpoint,
|
||||
WorkspaceThemeViewSet,
|
||||
WorkspaceUserProfileStatsEndpoint,
|
||||
WorkspaceUserActivityEndpoint,
|
||||
WorkspaceUserProfileEndpoint,
|
||||
WorkspaceUserProfileIssuesEndpoint,
|
||||
WorkspaceLabelsEndpoint,
|
||||
WorkspaceProjectMemberEndpoint,
|
||||
WorkspaceUserProfileStatsEndpoint,
|
||||
WorkspaceUserPropertiesEndpoint,
|
||||
WorkspaceStatesEndpoint,
|
||||
WorkspaceEstimatesEndpoint,
|
||||
ExportWorkspaceUserActivityEndpoint,
|
||||
WorkspaceModulesEndpoint,
|
||||
WorkspaceCyclesEndpoint,
|
||||
WorkSpaceViewSet,
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspace-slug-check/",
|
||||
@@ -237,4 +236,14 @@ urlpatterns = [
|
||||
WorkspaceCyclesEndpoint.as_view(),
|
||||
name="workspace-cycles",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/logo/",
|
||||
WorkspaceLogoEndpoint.as_view(),
|
||||
name="workspace-logo",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/logo/<str:workspace_id>/<str:logo_key>/",
|
||||
WorkspaceLogoEndpoint.as_view(),
|
||||
name="workspace-logo",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -6,6 +6,7 @@ from .project.base import (
|
||||
ProjectPublicCoverImagesEndpoint,
|
||||
ProjectDeployBoardViewSet,
|
||||
ProjectArchiveUnarchiveEndpoint,
|
||||
ProjectCoverImageEndpoint,
|
||||
)
|
||||
|
||||
from .project.invite import (
|
||||
@@ -26,6 +27,8 @@ from .user.base import (
|
||||
UpdateUserOnBoardedEndpoint,
|
||||
UpdateUserTourCompletedEndpoint,
|
||||
UserActivityEndpoint,
|
||||
UserAvatarEndpoint,
|
||||
UserCoverImageEndpoint,
|
||||
)
|
||||
|
||||
from .oauth import OauthEndpoint
|
||||
@@ -38,7 +41,8 @@ from .workspace.base import (
|
||||
WorkSpaceAvailabilityCheckEndpoint,
|
||||
UserWorkspaceDashboardEndpoint,
|
||||
WorkspaceThemeViewSet,
|
||||
ExportWorkspaceUserActivityEndpoint
|
||||
ExportWorkspaceUserActivityEndpoint,
|
||||
WorkspaceLogoEndpoint,
|
||||
)
|
||||
|
||||
from .workspace.member import (
|
||||
@@ -98,7 +102,7 @@ from .cycle.issue import (
|
||||
CycleIssueViewSet,
|
||||
)
|
||||
|
||||
from .asset.base import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
|
||||
from .asset.base import FileAssetEndpoint, FileAssetViewSet
|
||||
from .issue.base import (
|
||||
IssueListEndpoint,
|
||||
IssueViewSet,
|
||||
@@ -114,13 +118,12 @@ from .issue.archive import (
|
||||
IssueArchiveViewSet,
|
||||
)
|
||||
|
||||
from .issue.attachment import (
|
||||
IssueAttachmentEndpoint,
|
||||
)
|
||||
from .issue.attachment import IssueAttachmentEndpoint
|
||||
|
||||
from .issue.comment import (
|
||||
IssueCommentViewSet,
|
||||
CommentReactionViewSet,
|
||||
CommentAssetEndpoint,
|
||||
)
|
||||
|
||||
from .issue.draft import IssueDraftViewSet
|
||||
@@ -186,6 +189,7 @@ from .page.base import (
|
||||
PageFavoriteViewSet,
|
||||
PageLogEndpoint,
|
||||
SubPagesEndpoint,
|
||||
PageAssetEndpoint,
|
||||
)
|
||||
|
||||
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,43 +1,39 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core import serializers
|
||||
|
||||
# Django imports
|
||||
from django.db.models import (
|
||||
Func,
|
||||
F,
|
||||
Q,
|
||||
Func,
|
||||
OuterRef,
|
||||
Value,
|
||||
Q,
|
||||
UUIDField,
|
||||
Value,
|
||||
)
|
||||
from django.core import serializers
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models.functions import Coalesce
|
||||
from rest_framework import status
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.app.serializers import (
|
||||
CycleIssueSerializer,
|
||||
IssueSerializer,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.db.models import Cycle, CycleIssue, FileAsset, Issue, IssueLink
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet, WebhookMixin
|
||||
from plane.app.serializers import (
|
||||
IssueSerializer,
|
||||
CycleIssueSerializer,
|
||||
)
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.db.models import (
|
||||
Cycle,
|
||||
CycleIssue,
|
||||
Issue,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
@@ -115,16 +111,17 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
||||
@@ -36,7 +36,7 @@ from plane.db.models import (
|
||||
Dashboard,
|
||||
Project,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
IssueRelation,
|
||||
User,
|
||||
)
|
||||
@@ -123,8 +123,9 @@ def dashboard_assigned_issues(self, request, slug):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
@@ -278,8 +279,9 @@ def dashboard_created_issues(self, request, slug):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
||||
@@ -1,42 +1,53 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
# Django import
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q, Count, OuterRef, Func, F, Prefetch, Exists
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import Value, UUIDField
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db.models import (
|
||||
Count,
|
||||
Exists,
|
||||
F,
|
||||
Func,
|
||||
OuterRef,
|
||||
Prefetch,
|
||||
Q,
|
||||
UUIDField,
|
||||
Value,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# Django import
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from ..base import BaseViewSet
|
||||
from plane.app.permissions import ProjectBasePermission, ProjectLitePermission
|
||||
from plane.app.serializers import (
|
||||
InboxIssueSerializer,
|
||||
InboxSerializer,
|
||||
IssueCreateSerializer,
|
||||
IssueDetailSerializer,
|
||||
IssueSerializer,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.db.models import (
|
||||
FileAsset,
|
||||
Inbox,
|
||||
InboxIssue,
|
||||
Issue,
|
||||
State,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
ProjectMember,
|
||||
IssueReaction,
|
||||
IssueSubscriber,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
IssueCreateSerializer,
|
||||
IssueSerializer,
|
||||
InboxSerializer,
|
||||
InboxIssueSerializer,
|
||||
IssueDetailSerializer,
|
||||
ProjectMember,
|
||||
State,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
|
||||
# Module imports
|
||||
from ..base import BaseViewSet
|
||||
|
||||
|
||||
class InboxViewSet(BaseViewSet):
|
||||
@@ -118,8 +129,9 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
@@ -133,6 +145,15 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
label_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
@@ -405,12 +426,6 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_attachment",
|
||||
queryset=IssueAttachment.objects.select_related("issue"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_link",
|
||||
|
||||
@@ -1,53 +1,55 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import (
|
||||
Prefetch,
|
||||
OuterRef,
|
||||
Func,
|
||||
F,
|
||||
Q,
|
||||
Case,
|
||||
Value,
|
||||
CharField,
|
||||
When,
|
||||
Exists,
|
||||
Max,
|
||||
UUIDField,
|
||||
)
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db.models import (
|
||||
Case,
|
||||
CharField,
|
||||
Exists,
|
||||
F,
|
||||
Func,
|
||||
Max,
|
||||
OuterRef,
|
||||
Prefetch,
|
||||
Q,
|
||||
UUIDField,
|
||||
Value,
|
||||
When,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from rest_framework import status
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet
|
||||
from plane.app.serializers import (
|
||||
IssueSerializer,
|
||||
IssueFlatSerializer,
|
||||
IssueDetailSerializer,
|
||||
)
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
)
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
IssueSubscriber,
|
||||
IssueReaction,
|
||||
from plane.app.serializers import (
|
||||
IssueDetailSerializer,
|
||||
IssueFlatSerializer,
|
||||
IssueSerializer,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.db.models import (
|
||||
FileAsset,
|
||||
Issue,
|
||||
IssueLink,
|
||||
IssueReaction,
|
||||
IssueSubscriber,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet
|
||||
|
||||
|
||||
class IssueArchiveViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
@@ -77,16 +79,17 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
@@ -253,12 +256,6 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_attachment",
|
||||
queryset=IssueAttachment.objects.select_related("issue"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_link",
|
||||
|
||||
@@ -2,34 +2,43 @@
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.utils import timezone
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.parsers import MultiPartParser, FormParser
|
||||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from .. import BaseAPIView
|
||||
from plane.app.serializers import IssueAttachmentSerializer
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.db.models import IssueAttachment
|
||||
from plane.app.serializers import FileAssetSerializer
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.db.models import FileAsset, Workspace
|
||||
from plane.utils.presigned_url_generator import generate_download_presigned_url
|
||||
|
||||
|
||||
class IssueAttachmentEndpoint(BaseAPIView):
|
||||
serializer_class = IssueAttachmentSerializer
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
model = IssueAttachment
|
||||
parser_classes = (MultiPartParser, FormParser)
|
||||
parser_classes = (
|
||||
MultiPartParser,
|
||||
FormParser,
|
||||
JSONParser,
|
||||
)
|
||||
|
||||
def post(self, request, slug, project_id, issue_id):
|
||||
serializer = IssueAttachmentSerializer(data=request.data)
|
||||
serializer = FileAssetSerializer(data=request.data)
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
if serializer.is_valid():
|
||||
serializer.save(project_id=project_id, issue_id=issue_id)
|
||||
serializer.save(
|
||||
workspace=workspace,
|
||||
project_id=project_id,
|
||||
entity_identifier=issue_id,
|
||||
)
|
||||
issue_activity.delay(
|
||||
type="attachment.activity.created",
|
||||
requested_data=None,
|
||||
@@ -47,10 +56,19 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(self, request, slug, project_id, issue_id, pk):
|
||||
issue_attachment = IssueAttachment.objects.get(pk=pk)
|
||||
issue_attachment.asset.delete(save=False)
|
||||
issue_attachment.delete()
|
||||
def delete(
|
||||
self, request, slug, project_id, issue_id, workspace_id, asset_key
|
||||
):
|
||||
key = f"{workspace_id}/{asset_key}"
|
||||
asset = FileAsset.objects.get(
|
||||
asset=key,
|
||||
entity_identifier=issue_id,
|
||||
entity_type="issue_attachment",
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
asset.is_deleted = True
|
||||
asset.save()
|
||||
issue_activity.delay(
|
||||
type="attachment.activity.deleted",
|
||||
requested_data=None,
|
||||
@@ -62,12 +80,32 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def get(self, request, slug, project_id, issue_id):
|
||||
issue_attachments = IssueAttachment.objects.filter(
|
||||
issue_id=issue_id, workspace__slug=slug, project_id=project_id
|
||||
def get(
|
||||
self,
|
||||
request,
|
||||
slug,
|
||||
project_id,
|
||||
issue_id,
|
||||
workspace_id=None,
|
||||
asset_key=None,
|
||||
):
|
||||
if workspace_id and asset_key:
|
||||
key = f"{workspace_id}/{asset_key}"
|
||||
url = generate_download_presigned_url(
|
||||
key=key,
|
||||
host=request.get_host(),
|
||||
scheme=request.scheme,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
# For listing
|
||||
issue_attachments = FileAsset.objects.filter(
|
||||
entity_type="issue_attachment",
|
||||
entity_identifier=issue_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
serializer = IssueAttachmentSerializer(issue_attachments, many=True)
|
||||
serializer = FileAssetSerializer(issue_attachments, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -1,57 +1,58 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import (
|
||||
Prefetch,
|
||||
OuterRef,
|
||||
Func,
|
||||
F,
|
||||
Q,
|
||||
Case,
|
||||
Value,
|
||||
CharField,
|
||||
When,
|
||||
Exists,
|
||||
Max,
|
||||
)
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import UUIDField
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db.models import (
|
||||
Case,
|
||||
CharField,
|
||||
Exists,
|
||||
F,
|
||||
Func,
|
||||
Max,
|
||||
OuterRef,
|
||||
Prefetch,
|
||||
Q,
|
||||
UUIDField,
|
||||
Value,
|
||||
When,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from rest_framework import status
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet, BaseAPIView, WebhookMixin
|
||||
from plane.app.serializers import (
|
||||
IssuePropertySerializer,
|
||||
IssueSerializer,
|
||||
IssueCreateSerializer,
|
||||
IssueDetailSerializer,
|
||||
)
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
ProjectLitePermission,
|
||||
)
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
Issue,
|
||||
IssueProperty,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
IssueSubscriber,
|
||||
IssueReaction,
|
||||
from plane.app.serializers import (
|
||||
IssueCreateSerializer,
|
||||
IssueDetailSerializer,
|
||||
IssuePropertySerializer,
|
||||
IssueSerializer,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueLink,
|
||||
IssueProperty,
|
||||
IssueReaction,
|
||||
IssueSubscriber,
|
||||
Project,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
# Module imports
|
||||
from .. import BaseAPIView, BaseViewSet, WebhookMixin
|
||||
|
||||
|
||||
class IssueListEndpoint(BaseAPIView):
|
||||
|
||||
@@ -86,14 +87,6 @@ class IssueListEndpoint(BaseAPIView):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
@@ -281,14 +274,6 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
@@ -516,12 +501,6 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
|
||||
),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_attachment",
|
||||
queryset=IssueAttachment.objects.select_related("issue"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_link",
|
||||
|
||||
@@ -2,27 +2,33 @@
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import Exists
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db.models import Exists
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.utils import timezone
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet, WebhookMixin
|
||||
from plane.app.serializers import (
|
||||
IssueCommentSerializer,
|
||||
CommentReactionSerializer,
|
||||
)
|
||||
from plane.app.permissions import ProjectLitePermission
|
||||
from plane.app.serializers import (
|
||||
CommentReactionSerializer,
|
||||
FileAssetSerializer,
|
||||
IssueCommentSerializer,
|
||||
)
|
||||
from plane.app.views.base import BaseAPIView, BaseViewSet, WebhookMixin
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.db.models import (
|
||||
CommentReaction,
|
||||
FileAsset,
|
||||
IssueComment,
|
||||
ProjectMember,
|
||||
CommentReaction,
|
||||
Workspace,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.presigned_url_generator import generate_download_presigned_url
|
||||
|
||||
|
||||
class IssueCommentViewSet(WebhookMixin, BaseViewSet):
|
||||
@@ -219,3 +225,70 @@ class CommentReactionViewSet(BaseViewSet):
|
||||
)
|
||||
comment_reaction.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class CommentAssetEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectLitePermission,
|
||||
]
|
||||
parser_classes = (
|
||||
MultiPartParser,
|
||||
FormParser,
|
||||
JSONParser,
|
||||
)
|
||||
|
||||
def post(self, request, slug, project_id, comment_id):
|
||||
serializer = FileAssetSerializer(data=request.data)
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
workspace=workspace,
|
||||
project_id=project_id,
|
||||
entity_type="comment",
|
||||
entity_identifier=comment_id,
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(
|
||||
self, request, slug, project_id, comment_id, workspace_id, asset_key
|
||||
):
|
||||
key = f"{workspace_id}/{asset_key}"
|
||||
asset = FileAsset.objects.get(
|
||||
asset=key,
|
||||
entity_identifier=comment_id,
|
||||
entity_type="comment",
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
asset.is_deleted = True
|
||||
asset.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def get(
|
||||
self,
|
||||
request,
|
||||
slug,
|
||||
project_id,
|
||||
comment_id,
|
||||
workspace_id=None,
|
||||
asset_key=None,
|
||||
):
|
||||
if workspace_id and asset_key:
|
||||
key = f"{workspace_id}/{asset_key}"
|
||||
url = generate_download_presigned_url(
|
||||
key=key,
|
||||
host=request.get_host(),
|
||||
scheme=request.scheme,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
# For listing
|
||||
comment_assets = FileAsset.objects.filter(
|
||||
entity_type="comment",
|
||||
entity_identifier=comment_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
serializer = FileAssetSerializer(comment_assets, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -38,8 +38,8 @@ from plane.app.serializers import (
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.db.models import (
|
||||
FileAsset,
|
||||
Issue,
|
||||
IssueAttachment,
|
||||
IssueLink,
|
||||
IssueReaction,
|
||||
IssueSubscriber,
|
||||
@@ -73,16 +73,17 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
@@ -314,12 +315,6 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_attachment",
|
||||
queryset=IssueAttachment.objects.select_related("issue"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_link",
|
||||
|
||||
@@ -1,37 +1,38 @@
|
||||
# Python imports
|
||||
import json
|
||||
from collections import defaultdict
|
||||
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import (
|
||||
F,
|
||||
Func,
|
||||
OuterRef,
|
||||
Q,
|
||||
UUIDField,
|
||||
Value,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import (
|
||||
OuterRef,
|
||||
Func,
|
||||
F,
|
||||
Q,
|
||||
Value,
|
||||
UUIDField,
|
||||
)
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models.functions import Coalesce
|
||||
from rest_framework import status
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from .. import BaseAPIView
|
||||
from plane.app.serializers import IssueSerializer
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.app.serializers import IssueSerializer
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from collections import defaultdict
|
||||
|
||||
# Module imports
|
||||
from .. import BaseAPIView
|
||||
|
||||
|
||||
class SubIssuesEndpoint(BaseAPIView):
|
||||
@@ -54,14 +55,6 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
|
||||
@@ -1,36 +1,37 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
# Django Imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import F, OuterRef, Func, Q
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import Value, UUIDField
|
||||
from django.db.models import F, Func, OuterRef, Q, UUIDField, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# Django Imports
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from rest_framework import status
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.app.serializers import (
|
||||
IssueSerializer,
|
||||
ModuleIssueSerializer,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.db.models import (
|
||||
FileAsset,
|
||||
Issue,
|
||||
IssueLink,
|
||||
ModuleIssue,
|
||||
Project,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet, WebhookMixin
|
||||
from plane.app.serializers import (
|
||||
ModuleIssueSerializer,
|
||||
IssueSerializer,
|
||||
)
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.db.models import (
|
||||
ModuleIssue,
|
||||
Project,
|
||||
Issue,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
@@ -65,16 +66,17 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
||||
@@ -4,26 +4,33 @@ from datetime import datetime
|
||||
# Django imports
|
||||
from django.db import connection
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.app.serializers import (
|
||||
FileAssetSerializer,
|
||||
PageFavoriteSerializer,
|
||||
PageLogSerializer,
|
||||
PageSerializer,
|
||||
SubPageSerializer,
|
||||
)
|
||||
from plane.db.models import (
|
||||
FileAsset,
|
||||
Page,
|
||||
PageFavorite,
|
||||
PageLog,
|
||||
ProjectMember,
|
||||
Workspace,
|
||||
)
|
||||
from plane.utils.presigned_url_generator import generate_download_presigned_url
|
||||
|
||||
# Module imports
|
||||
from ..base import BaseAPIView, BaseViewSet
|
||||
@@ -359,3 +366,70 @@ class SubPagesEndpoint(BaseAPIView):
|
||||
return Response(
|
||||
SubPageSerializer(pages, many=True).data, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
|
||||
class PageAssetEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
parser_classes = (
|
||||
MultiPartParser,
|
||||
FormParser,
|
||||
JSONParser,
|
||||
)
|
||||
|
||||
def post(self, request, slug, project_id, page_id):
|
||||
serializer = FileAssetSerializer(data=request.data)
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
workspace=workspace,
|
||||
project_id=project_id,
|
||||
entity_type="page",
|
||||
entity_identifier=page_id,
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(
|
||||
self, request, slug, project_id, page_id, workspace_id, asset_key
|
||||
):
|
||||
key = f"{workspace_id}/{asset_key}"
|
||||
asset = FileAsset.objects.get(
|
||||
asset=key,
|
||||
entity_identifier=page_id,
|
||||
entity_type="page",
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
asset.is_deleted = True
|
||||
asset.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def get(
|
||||
self,
|
||||
request,
|
||||
slug,
|
||||
project_id,
|
||||
page_id,
|
||||
workspace_id=None,
|
||||
asset_key=None,
|
||||
):
|
||||
if workspace_id and asset_key:
|
||||
key = f"{workspace_id}/{asset_key}"
|
||||
url = generate_download_presigned_url(
|
||||
key=key,
|
||||
host=request.get_host(),
|
||||
scheme=request.scheme,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
# For listing
|
||||
page_assets = FileAsset.objects.filter(
|
||||
entity_type="page",
|
||||
entity_identifier=page_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
serializer = FileAssetSerializer(page_assets, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -2,54 +2,57 @@
|
||||
import boto3
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import (
|
||||
Prefetch,
|
||||
Q,
|
||||
Exists,
|
||||
OuterRef,
|
||||
F,
|
||||
Func,
|
||||
OuterRef,
|
||||
Prefetch,
|
||||
Q,
|
||||
Subquery,
|
||||
)
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.utils import timezone
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework import serializers
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
# Module imports
|
||||
from plane.app.views.base import BaseViewSet, BaseAPIView, WebhookMixin
|
||||
from plane.app.serializers import (
|
||||
ProjectSerializer,
|
||||
ProjectListSerializer,
|
||||
ProjectFavoriteSerializer,
|
||||
ProjectDeployBoardSerializer,
|
||||
)
|
||||
|
||||
from plane.app.permissions import (
|
||||
ProjectBasePermission,
|
||||
ProjectMemberPermission,
|
||||
)
|
||||
|
||||
from plane.app.serializers import (
|
||||
FileAssetSerializer,
|
||||
ProjectDeployBoardSerializer,
|
||||
ProjectFavoriteSerializer,
|
||||
ProjectListSerializer,
|
||||
ProjectLiteSerializer,
|
||||
ProjectSerializer,
|
||||
)
|
||||
from plane.app.views.base import BaseAPIView, BaseViewSet, WebhookMixin
|
||||
from plane.db.models import (
|
||||
Cycle,
|
||||
FileAsset,
|
||||
Inbox,
|
||||
Issue,
|
||||
IssueProperty,
|
||||
Module,
|
||||
Project,
|
||||
ProjectMember,
|
||||
Workspace,
|
||||
State,
|
||||
ProjectDeployBoard,
|
||||
ProjectFavorite,
|
||||
ProjectIdentifier,
|
||||
Module,
|
||||
Cycle,
|
||||
Inbox,
|
||||
ProjectDeployBoard,
|
||||
IssueProperty,
|
||||
Issue,
|
||||
ProjectMember,
|
||||
State,
|
||||
Workspace,
|
||||
)
|
||||
from plane.utils.cache import cache_response
|
||||
from plane.utils.presigned_url_generator import generate_download_presigned_url
|
||||
|
||||
|
||||
class ProjectViewSet(WebhookMixin, BaseViewSet):
|
||||
@@ -650,3 +653,51 @@ class ProjectDeployBoardViewSet(BaseViewSet):
|
||||
|
||||
serializer = ProjectDeployBoardSerializer(project_deploy_board)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class ProjectCoverImageEndpoint(BaseAPIView):
|
||||
|
||||
parser_classes = (
|
||||
MultiPartParser,
|
||||
FormParser,
|
||||
JSONParser,
|
||||
)
|
||||
|
||||
def get_permissions(self):
|
||||
if self.request.method == "POST" or self.request.method == "DELETE":
|
||||
return [
|
||||
IsAuthenticated(),
|
||||
]
|
||||
return [
|
||||
AllowAny(),
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id, workspace_id, cover_image_key):
|
||||
key = f"{workspace_id}/{cover_image_key}"
|
||||
url = generate_download_presigned_url(
|
||||
key=key,
|
||||
host=request.get_host(),
|
||||
scheme=request.scheme,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
def post(self, request, slug, project_id):
|
||||
serializer = FileAssetSerializer(data=request.data)
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
if serializer.is_valid():
|
||||
serializer.save(workspace=workspace)
|
||||
project = Project.objects.get(pk=project_id)
|
||||
project.cover_image = f"/api/workspaces/{slug}/projects/{project_id}/cover-image/{serializer.data['asset']}/"
|
||||
project.save()
|
||||
project_serializer = ProjectLiteSerializer(project)
|
||||
return Response(
|
||||
project_serializer.data, status=status.HTTP_201_CREATED
|
||||
)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(self, request, project_id, workspace_id, cover_image_key):
|
||||
key = f"{workspace_id}/{cover_image_key}"
|
||||
file_asset = FileAsset.objects.get(asset=key)
|
||||
file_asset.is_deleted = True
|
||||
file_asset.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
# Django imports
|
||||
from django.db.models import Case, Count, IntegerField, Q, When
|
||||
from django.http import HttpResponseRedirect
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.app.serializers import (
|
||||
FileAssetSerializer,
|
||||
IssueActivitySerializer,
|
||||
UserMeSerializer,
|
||||
UserMeSettingsSerializer,
|
||||
UserSerializer,
|
||||
)
|
||||
from plane.app.views.base import BaseAPIView, BaseViewSet
|
||||
from plane.db.models import IssueActivity, ProjectMember, User, WorkspaceMember
|
||||
from plane.db.models import (
|
||||
FileAsset,
|
||||
IssueActivity,
|
||||
ProjectMember,
|
||||
User,
|
||||
WorkspaceMember,
|
||||
)
|
||||
from plane.license.models import Instance, InstanceAdmin
|
||||
from plane.utils.cache import cache_response, invalidate_cache
|
||||
from plane.utils.paginator import BasePaginator
|
||||
from plane.utils.presigned_url_generator import generate_download_presigned_url
|
||||
|
||||
|
||||
class UserEndpoint(BaseViewSet):
|
||||
@@ -189,3 +200,97 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator):
|
||||
issue_activities, many=True
|
||||
).data,
|
||||
)
|
||||
|
||||
|
||||
class UserAvatarEndpoint(BaseAPIView):
|
||||
|
||||
parser_classes = (
|
||||
MultiPartParser,
|
||||
FormParser,
|
||||
JSONParser,
|
||||
)
|
||||
|
||||
def get_permissions(self):
|
||||
if self.request.method == "POST" or self.request.method == "DELETE":
|
||||
return [
|
||||
IsAuthenticated(),
|
||||
]
|
||||
return [
|
||||
AllowAny(),
|
||||
]
|
||||
|
||||
def get(self, request, avatar_key):
|
||||
url = generate_download_presigned_url(
|
||||
key=avatar_key,
|
||||
host=request.get_host(),
|
||||
scheme=request.scheme,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
def post(self, request):
|
||||
serializer = FileAssetSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
# Get the workspace
|
||||
serializer.save()
|
||||
user = request.user
|
||||
user.avatar = "/api/users/avatar/" + serializer.data["asset"] + "/"
|
||||
user.save()
|
||||
user_serializer = UserMeSerializer(user)
|
||||
return Response(
|
||||
user_serializer.data, status=status.HTTP_201_CREATED
|
||||
)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(self, request, avatar_key):
|
||||
file_asset = FileAsset.objects.get(asset=avatar_key)
|
||||
file_asset.is_deleted = True
|
||||
file_asset.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class UserCoverImageEndpoint(BaseAPIView):
|
||||
|
||||
parser_classes = (
|
||||
MultiPartParser,
|
||||
FormParser,
|
||||
JSONParser,
|
||||
)
|
||||
|
||||
def get_permissions(self):
|
||||
if self.request.method == "POST" or self.request.method == "DELETE":
|
||||
return [
|
||||
IsAuthenticated(),
|
||||
]
|
||||
return [
|
||||
AllowAny(),
|
||||
]
|
||||
|
||||
def get(self, request, cover_image_key):
|
||||
url = generate_download_presigned_url(
|
||||
key=cover_image_key,
|
||||
host=request.get_host(),
|
||||
scheme=request.scheme,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
def post(self, request):
|
||||
serializer = FileAssetSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
# Get the workspace
|
||||
serializer.save()
|
||||
user = request.user
|
||||
user.avatar = (
|
||||
"/api/users/cover-image/" + serializer.data["asset"] + "/"
|
||||
)
|
||||
user.save()
|
||||
user_serializer = UserMeSerializer(user)
|
||||
return Response(
|
||||
user_serializer.data, status=status.HTTP_201_CREATED
|
||||
)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(self, request, cover_image_key):
|
||||
file_asset = FileAsset.objects.get(asset=cover_image_key)
|
||||
file_asset.is_deleted = True
|
||||
file_asset.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -1,47 +1,48 @@
|
||||
# Django imports
|
||||
from django.db.models import (
|
||||
Q,
|
||||
OuterRef,
|
||||
Func,
|
||||
F,
|
||||
Case,
|
||||
Value,
|
||||
CharField,
|
||||
When,
|
||||
Exists,
|
||||
Max,
|
||||
)
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import UUIDField
|
||||
from django.db.models import (
|
||||
Case,
|
||||
CharField,
|
||||
Exists,
|
||||
F,
|
||||
Func,
|
||||
Max,
|
||||
OuterRef,
|
||||
Q,
|
||||
UUIDField,
|
||||
Value,
|
||||
When,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from rest_framework import status
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
WorkspaceEntityPermission,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
IssueSerializer,
|
||||
IssueViewFavoriteSerializer,
|
||||
IssueViewSerializer,
|
||||
)
|
||||
from plane.db.models import (
|
||||
FileAsset,
|
||||
Issue,
|
||||
IssueLink,
|
||||
IssueView,
|
||||
IssueViewFavorite,
|
||||
Workspace,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet
|
||||
from plane.app.serializers import (
|
||||
IssueViewSerializer,
|
||||
IssueSerializer,
|
||||
IssueViewFavoriteSerializer,
|
||||
)
|
||||
from plane.app.permissions import (
|
||||
WorkspaceEntityPermission,
|
||||
ProjectEntityPermission,
|
||||
)
|
||||
from plane.db.models import (
|
||||
Workspace,
|
||||
IssueView,
|
||||
Issue,
|
||||
IssueViewFavorite,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
class GlobalViewViewSet(BaseViewSet):
|
||||
@@ -97,8 +98,9 @@ class GlobalViewIssuesViewSet(BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
||||
@@ -4,6 +4,8 @@ import io
|
||||
from datetime import date
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import (
|
||||
Count,
|
||||
@@ -15,28 +17,29 @@ from django.db.models import (
|
||||
)
|
||||
from django.db.models.fields import DateField
|
||||
from django.db.models.functions import Cast, ExtractDay, ExtractWeek
|
||||
|
||||
# Django imports
|
||||
from django.http import HttpResponse
|
||||
from django.http import HttpResponse, HttpResponseRedirect
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party modules
|
||||
from rest_framework import status
|
||||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.app.permissions import (
|
||||
WorkSpaceAdminPermission,
|
||||
WorkSpaceBasePermission,
|
||||
WorkspaceEntityPermission,
|
||||
)
|
||||
|
||||
# Module imports
|
||||
from plane.app.serializers import (
|
||||
FileAssetSerializer,
|
||||
WorkSpaceSerializer,
|
||||
WorkspaceThemeSerializer,
|
||||
)
|
||||
from plane.app.views.base import BaseAPIView, BaseViewSet
|
||||
from plane.db.models import (
|
||||
FileAsset,
|
||||
Issue,
|
||||
IssueActivity,
|
||||
Workspace,
|
||||
@@ -44,6 +47,7 @@ from plane.db.models import (
|
||||
WorkspaceTheme,
|
||||
)
|
||||
from plane.utils.cache import cache_response, invalidate_cache
|
||||
from plane.utils.presigned_url_generator import generate_download_presigned_url
|
||||
|
||||
|
||||
class WorkSpaceViewSet(BaseViewSet):
|
||||
@@ -416,3 +420,52 @@ class ExportWorkspaceUserActivityEndpoint(BaseAPIView):
|
||||
'attachment; filename="workspace-user-activity.csv"'
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
class WorkspaceLogoEndpoint(BaseAPIView):
|
||||
|
||||
parser_classes = (
|
||||
MultiPartParser,
|
||||
FormParser,
|
||||
JSONParser,
|
||||
)
|
||||
|
||||
def get_permissions(self):
|
||||
if self.request.method == "POST" or self.request.method == "DELETE":
|
||||
return [
|
||||
IsAuthenticated(),
|
||||
]
|
||||
return [
|
||||
AllowAny(),
|
||||
]
|
||||
|
||||
def get(self, request, slug, workspace_id, logo_key):
|
||||
key = f"{workspace_id}/{logo_key}"
|
||||
url = generate_download_presigned_url(
|
||||
key=key,
|
||||
host=request.get_host(),
|
||||
scheme=request.scheme,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
def post(self, request, slug):
|
||||
serializer = FileAssetSerializer(data=request.data)
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
if serializer.is_valid():
|
||||
serializer.save(workspace=workspace)
|
||||
workspace.logo = (
|
||||
f"/api/workspaces/{slug}/logo/{serializer.data['asset']}/"
|
||||
)
|
||||
workspace.save()
|
||||
workspace_serializer = WorkSpaceSerializer(workspace)
|
||||
return Response(
|
||||
workspace_serializer.data, status=status.HTTP_201_CREATED
|
||||
)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(self, request, slug, workspace_id, logo_key):
|
||||
key = f"{workspace_id}/{logo_key}"
|
||||
file_asset = FileAsset.objects.get(asset=key)
|
||||
file_asset.is_deleted = True
|
||||
file_asset.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -1,60 +1,61 @@
|
||||
# Python imports
|
||||
from datetime import date
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import (
|
||||
Case,
|
||||
CharField,
|
||||
Count,
|
||||
F,
|
||||
Func,
|
||||
IntegerField,
|
||||
Max,
|
||||
OuterRef,
|
||||
Q,
|
||||
UUIDField,
|
||||
Value,
|
||||
When,
|
||||
)
|
||||
from django.db.models.fields import DateField
|
||||
from django.db.models.functions import Cast, Coalesce, ExtractWeek
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import (
|
||||
OuterRef,
|
||||
Func,
|
||||
F,
|
||||
Q,
|
||||
Count,
|
||||
Case,
|
||||
Value,
|
||||
CharField,
|
||||
When,
|
||||
Max,
|
||||
IntegerField,
|
||||
UUIDField,
|
||||
)
|
||||
from django.db.models.functions import ExtractWeek, Cast
|
||||
from django.db.models.fields import DateField
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# Third party modules
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plane.app.permissions import (
|
||||
WorkspaceEntityPermission,
|
||||
WorkspaceViewerPermission,
|
||||
)
|
||||
|
||||
# Module imports
|
||||
from plane.app.serializers import (
|
||||
WorkSpaceSerializer,
|
||||
ProjectMemberSerializer,
|
||||
IssueActivitySerializer,
|
||||
IssueSerializer,
|
||||
ProjectMemberSerializer,
|
||||
WorkSpaceSerializer,
|
||||
WorkspaceUserPropertiesSerializer,
|
||||
)
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.db.models import (
|
||||
User,
|
||||
Workspace,
|
||||
ProjectMember,
|
||||
IssueActivity,
|
||||
CycleIssue,
|
||||
FileAsset,
|
||||
Issue,
|
||||
IssueActivity,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
IssueSubscriber,
|
||||
Project,
|
||||
ProjectMember,
|
||||
User,
|
||||
Workspace,
|
||||
WorkspaceMember,
|
||||
CycleIssue,
|
||||
WorkspaceUserProperties,
|
||||
)
|
||||
from plane.app.permissions import (
|
||||
WorkspaceEntityPermission,
|
||||
WorkspaceViewerPermission,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
@@ -137,16 +138,17 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# Python imports
|
||||
from datetime import timedelta
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import FileAsset
|
||||
|
||||
@@ -26,3 +26,14 @@ def delete_file_asset():
|
||||
file_asset.asset.delete(save=False)
|
||||
# Delete the file object
|
||||
file_asset.delete()
|
||||
|
||||
|
||||
@shared_task
|
||||
def file_asset_size():
|
||||
asset_size = []
|
||||
for asset in FileAsset.objects.filter(size__isnull=True):
|
||||
asset.size = asset.asset.size
|
||||
asset_size.append(asset)
|
||||
|
||||
FileAsset.objects.bulk_update(asset_size, ["size"], batch_size=50)
|
||||
print("File asset size updated successfully")
|
||||
|
||||
@@ -1,43 +1,26 @@
|
||||
# Python imports
|
||||
import boto3
|
||||
import json
|
||||
from botocore.exceptions import ClientError
|
||||
from django.conf import settings
|
||||
|
||||
# Django imports
|
||||
from django.core.management import BaseCommand
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Create the default bucket for the instance"
|
||||
|
||||
def set_bucket_public_policy(self, s3_client, bucket_name):
|
||||
public_policy = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": [f"arn:aws:s3:::{bucket_name}/*"],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def set_bucket_private_policy(self, s3_client, bucket_name):
|
||||
try:
|
||||
s3_client.put_bucket_policy(
|
||||
Bucket=bucket_name, Policy=json.dumps(public_policy)
|
||||
)
|
||||
s3_client.delete_bucket_policy(Bucket=bucket_name)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Public read access policy set for bucket '{bucket_name}'."
|
||||
f"Public access policy removed for bucket '{bucket_name}', bucket is now private."
|
||||
)
|
||||
)
|
||||
except ClientError as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f"Error setting public read access policy: {e}"
|
||||
)
|
||||
self.style.ERROR(f"Error removing public access policy: {e}")
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
@@ -58,7 +41,7 @@ class Command(BaseCommand):
|
||||
# Check if the bucket exists
|
||||
s3_client.head_bucket(Bucket=bucket_name)
|
||||
|
||||
self.set_bucket_public_policy(s3_client, bucket_name)
|
||||
self.set_bucket_private_policy(s3_client, bucket_name)
|
||||
except ClientError as e:
|
||||
error_code = int(e.response["Error"]["Code"])
|
||||
bucket_name = settings.AWS_STORAGE_BUCKET_NAME
|
||||
@@ -76,7 +59,6 @@ class Command(BaseCommand):
|
||||
f"Bucket '{bucket_name}' created successfully."
|
||||
)
|
||||
)
|
||||
self.set_bucket_public_policy(s3_client, bucket_name)
|
||||
except ClientError as create_error:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
|
||||
21
apiserver/plane/db/management/commands/file_asset_size.py
Normal file
21
apiserver/plane/db/management/commands/file_asset_size.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# Python imports
|
||||
|
||||
# Django imports
|
||||
from django.core.management import BaseCommand
|
||||
|
||||
# Module imports
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Check the file asset size of the file"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
|
||||
from plane.bgtasks.file_asset_task import file_asset_size
|
||||
|
||||
# Start the queueing
|
||||
file_asset_size.delay()
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("File asset size pushed to queue")
|
||||
)
|
||||
238
apiserver/plane/db/migrations/0063_auto_20240321_0913.py
Normal file
238
apiserver/plane/db/migrations/0063_auto_20240321_0913.py
Normal file
@@ -0,0 +1,238 @@
|
||||
# Generated by Django 4.2.10 on 2024-03-21 09:13
|
||||
|
||||
import django.db.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import plane.db.models.asset
|
||||
|
||||
|
||||
def update_user_urls(apps, schema_editor):
|
||||
# Check if the app is using minio or s3
|
||||
if settings.USE_MINIO:
|
||||
prefix1 = (
|
||||
f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/"
|
||||
)
|
||||
prefix2 = prefix1
|
||||
else:
|
||||
prefix1 = f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/"
|
||||
prefix2 = (
|
||||
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com/"
|
||||
)
|
||||
|
||||
User = apps.get_model("db", "User")
|
||||
bulk_users = []
|
||||
|
||||
# Loop through all the users and update the cover image
|
||||
for user in User.objects.all():
|
||||
# prefix 1
|
||||
if user.avatar and (user.avatar.startswith(prefix1)):
|
||||
avatar_key = user.avatar
|
||||
user.avatar = avatar_key[len(prefix1) :]
|
||||
bulk_users.append(user)
|
||||
|
||||
# prefix 2
|
||||
if (
|
||||
not settings.USE_MINIO
|
||||
and user.avatar
|
||||
and user.avatar.startswith(prefix2)
|
||||
):
|
||||
avatar_key = user.avatar
|
||||
user.avatar = avatar_key[len(prefix2) :]
|
||||
bulk_users.append(user)
|
||||
|
||||
# prefix 1
|
||||
if user.cover_image and (user.cover_image.startswith(prefix1)):
|
||||
cover_image_key = user.cover_image
|
||||
user.cover_image = cover_image_key[len(prefix1) :]
|
||||
bulk_users.append(user)
|
||||
|
||||
# prefix 2
|
||||
if (
|
||||
not settings.USE_MINIO
|
||||
and user.cover_image
|
||||
and user.cover_image.startswith(prefix2)
|
||||
):
|
||||
cover_image_key = user.cover_image
|
||||
user.cover_image = cover_image_key[len(prefix2) :]
|
||||
bulk_users.append(user)
|
||||
|
||||
User.objects.bulk_update(
|
||||
bulk_users, ["avatar", "cover_image"], batch_size=100
|
||||
)
|
||||
|
||||
|
||||
def update_workspace_urls(apps, schema_editor):
|
||||
# Check if the app is using minio or s3
|
||||
if settings.USE_MINIO:
|
||||
prefix1 = (
|
||||
f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/"
|
||||
)
|
||||
prefix2 = prefix1
|
||||
else:
|
||||
prefix1 = f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/"
|
||||
prefix2 = (
|
||||
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com/"
|
||||
)
|
||||
|
||||
Workspace = apps.get_model("db", "Workspace")
|
||||
bulk_workspaces = []
|
||||
|
||||
# Loop through all the users and update the cover image
|
||||
for workspace in Workspace.objects.all():
|
||||
# prefix 1
|
||||
if workspace.logo and (workspace.logo.startswith(prefix1)):
|
||||
logo_key = workspace.logo
|
||||
workspace.logo = logo_key[len(prefix1) :]
|
||||
bulk_workspaces.append(workspace)
|
||||
|
||||
# prefix 2
|
||||
if (
|
||||
not settings.USE_MINIO
|
||||
and workspace.logo
|
||||
and (workspace.logo.startswith(prefix2))
|
||||
):
|
||||
logo_key = workspace.logo
|
||||
workspace.logo = logo_key[len(prefix2) :]
|
||||
bulk_workspaces.append(workspace)
|
||||
|
||||
Workspace.objects.bulk_update(bulk_workspaces, ["logo"], batch_size=100)
|
||||
|
||||
|
||||
def update_project_urls(apps, schema_editor):
|
||||
file_assets = {}
|
||||
|
||||
# Check if the app is using minio or s3
|
||||
if settings.USE_MINIO:
|
||||
prefix1 = (
|
||||
f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/"
|
||||
)
|
||||
prefix2 = prefix1
|
||||
else:
|
||||
prefix1 = f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/"
|
||||
prefix2 = (
|
||||
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com/"
|
||||
)
|
||||
|
||||
Project = apps.get_model("db", "Project")
|
||||
bulk_projects = []
|
||||
|
||||
# Loop through all the users and update the cover image
|
||||
for project in Project.objects.all():
|
||||
# prefix 1
|
||||
if project.cover_image and (project.cover_image.startswith(prefix1)):
|
||||
cover_image_key = project.cover_image
|
||||
project.cover_image = cover_image_key[len(prefix1) :]
|
||||
file_assets[cover_image_key[len(prefix1) :]] = str(project.id)
|
||||
bulk_projects.append(project)
|
||||
|
||||
# prefix 2
|
||||
if (
|
||||
not settings.USE_MINIO
|
||||
and project.cover_image
|
||||
and (project.cover_image.startswith(prefix2))
|
||||
):
|
||||
cover_image_key = project.cover_image
|
||||
project.cover_image = cover_image_key[len(prefix2) :]
|
||||
file_assets[cover_image_key[len(prefix2) :]] = str(project.id)
|
||||
bulk_projects.append(project)
|
||||
|
||||
Project.objects.bulk_update(bulk_projects, ["cover_image"], batch_size=100)
|
||||
|
||||
FileAsset = apps.get_model("db", "FileAsset")
|
||||
bulk_assets = []
|
||||
for asset in FileAsset.objects.filter(asset__in=file_assets.keys()):
|
||||
asset.project_id = file_assets[str(asset.asset)]
|
||||
bulk_assets.append(asset)
|
||||
|
||||
FileAsset.objects.bulk_update(
|
||||
bulk_assets,
|
||||
[
|
||||
"project_id",
|
||||
],
|
||||
batch_size=100,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("db", "0062_cycle_archived_at_module_archived_at_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="fileasset",
|
||||
name="asset",
|
||||
field=models.FileField(
|
||||
storage=plane.settings.storage.S3PrivateBucketStorage(),
|
||||
upload_to=plane.db.models.asset.get_upload_path,
|
||||
validators=[plane.db.models.asset.file_size],
|
||||
),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="issueactivity",
|
||||
name="attachments",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="issuecomment",
|
||||
name="attachments",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="integration",
|
||||
name="avatar_url",
|
||||
field=models.CharField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="project",
|
||||
name="cover_image",
|
||||
field=models.CharField(blank=True, max_length=800, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="cover_image",
|
||||
field=models.CharField(blank=True, max_length=800, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="workspace",
|
||||
name="logo",
|
||||
field=models.CharField(blank=True, null=True, verbose_name="Logo"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="fileasset",
|
||||
name="size",
|
||||
field=models.PositiveBigIntegerField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="fileasset",
|
||||
name="entity_identifier",
|
||||
field=models.UUIDField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="fileasset",
|
||||
name="entity_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("issue_attachment", "Issue Attachment"),
|
||||
("issue_description", "Issue Description"),
|
||||
("comment", "Comment"),
|
||||
("page", "Page"),
|
||||
],
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="fileasset",
|
||||
name="project",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="assets",
|
||||
to="db.project",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(update_user_urls),
|
||||
migrations.RunPython(update_workspace_urls),
|
||||
migrations.RunPython(update_project_urls),
|
||||
]
|
||||
228
apiserver/plane/db/migrations/0064_auto_20240321_0915.py
Normal file
228
apiserver/plane/db/migrations/0064_auto_20240321_0915.py
Normal file
@@ -0,0 +1,228 @@
|
||||
# Generated by Django 4.2.10 on 2024-03-21 09:15
|
||||
|
||||
# Third party imports
|
||||
from bs4 import BeautifulSoup
|
||||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def convert_issue_description_image_sources(apps, schema_editor):
|
||||
|
||||
file_assets = {}
|
||||
|
||||
if settings.USE_MINIO:
|
||||
prefix1 = (
|
||||
f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/"
|
||||
)
|
||||
prefix2 = prefix1
|
||||
else:
|
||||
prefix1 = f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/"
|
||||
prefix2 = (
|
||||
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com/"
|
||||
)
|
||||
|
||||
Issue = apps.get_model("db", "Issue")
|
||||
|
||||
bulk_issues = []
|
||||
|
||||
for issue in Issue.objects.all():
|
||||
# Parse the html
|
||||
soup = BeautifulSoup(issue.description_html, "lxml")
|
||||
img_tags = soup.find_all("img")
|
||||
for img in img_tags:
|
||||
src = img.get("src", "")
|
||||
if src and (src.startswith(prefix1)):
|
||||
img["src"] = src[len(prefix1) :]
|
||||
file_assets[src[len(prefix1) :]] = {
|
||||
"project_id": str(issue.project_id),
|
||||
"issue_id": str(issue.id),
|
||||
}
|
||||
issue.description_html = str(soup)
|
||||
bulk_issues.append(issue)
|
||||
|
||||
# prefix 2
|
||||
if not settings.USE_MINIO and src and src.startswith(prefix2):
|
||||
img["src"] = src[len(prefix2) :]
|
||||
file_assets[src[len(prefix2) :]] = {
|
||||
"project_id": str(issue.project_id),
|
||||
"issue_id": str(issue.id),
|
||||
}
|
||||
issue.description_html = str(soup)
|
||||
bulk_issues.append(issue)
|
||||
|
||||
# Update the issue description htmls
|
||||
Issue.objects.bulk_update(
|
||||
bulk_issues, ["description_html"], batch_size=1000
|
||||
)
|
||||
|
||||
# Update file assets
|
||||
FileAsset = apps.get_model("db", "FileAsset")
|
||||
bulk_assets = []
|
||||
for asset in FileAsset.objects.filter(asset__in=file_assets.keys()):
|
||||
asset.project_id = file_assets[str(asset.asset)]["project_id"]
|
||||
asset.entity_identifier = file_assets[str(asset.asset)]["issue_id"]
|
||||
asset.entity_type = "issue_description"
|
||||
bulk_assets.append(asset)
|
||||
|
||||
FileAsset.objects.bulk_update(
|
||||
bulk_assets,
|
||||
[
|
||||
"project_id",
|
||||
"entity_identifier",
|
||||
"entity_type",
|
||||
],
|
||||
batch_size=100,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
def convert_page_image_sources(apps, schema_editor):
|
||||
|
||||
file_assets = {}
|
||||
|
||||
if settings.USE_MINIO:
|
||||
prefix1 = (
|
||||
f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/"
|
||||
)
|
||||
prefix2 = prefix1
|
||||
else:
|
||||
prefix1 = f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/"
|
||||
prefix2 = (
|
||||
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com/"
|
||||
)
|
||||
|
||||
Page = apps.get_model("db", "Page")
|
||||
FileAsset = apps.get_model("db", "FileAsset")
|
||||
|
||||
bulk_pages = []
|
||||
bulk_assets = {}
|
||||
|
||||
for page in Page.objects.all():
|
||||
# Parse the html
|
||||
soup = BeautifulSoup(page.description_html, "lxml")
|
||||
img_tags = soup.find_all("img")
|
||||
for img in img_tags:
|
||||
src = img.get("src", "")
|
||||
if src and (src.startswith(prefix1)):
|
||||
img["src"] = src[len(prefix1) :]
|
||||
file_assets[src[len(prefix1) :]] = {
|
||||
"project_id": str(page.project_id),
|
||||
"page_id": str(page.id),
|
||||
}
|
||||
page.description_html = str(soup)
|
||||
bulk_pages.append(page)
|
||||
|
||||
# prefix 2
|
||||
if not settings.USE_MINIO and src and src.startswith(prefix2):
|
||||
img["src"] = src[len(prefix2) :]
|
||||
file_assets[src[len(prefix2) :]] = {
|
||||
"project_id": str(page.project_id),
|
||||
"page_id": str(page.id),
|
||||
}
|
||||
page.description_html = str(soup)
|
||||
bulk_pages.append(page)
|
||||
|
||||
Page.objects.bulk_update(bulk_pages, ["description_html"], batch_size=1000)
|
||||
|
||||
# Update file assets
|
||||
FileAsset = apps.get_model("db", "FileAsset")
|
||||
bulk_assets = []
|
||||
for asset in FileAsset.objects.filter(asset__in=file_assets.keys()):
|
||||
asset.project_id = file_assets[str(asset.asset)]["project_id"]
|
||||
asset.entity_identifier = file_assets[str(asset.asset)]["page_id"]
|
||||
asset.entity_type = "page"
|
||||
bulk_assets.append(asset)
|
||||
|
||||
FileAsset.objects.bulk_update(
|
||||
bulk_assets,
|
||||
[
|
||||
"project_id",
|
||||
"entity_identifier",
|
||||
"entity_type",
|
||||
],
|
||||
batch_size=100,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
def convert_comment_image_sources(apps, schema_editor):
|
||||
|
||||
file_assets = {}
|
||||
|
||||
if settings.USE_MINIO:
|
||||
prefix1 = (
|
||||
f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/"
|
||||
)
|
||||
prefix2 = prefix1
|
||||
else:
|
||||
prefix1 = f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/"
|
||||
prefix2 = (
|
||||
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com/"
|
||||
)
|
||||
|
||||
IssueComment = apps.get_model("db", "IssueComment")
|
||||
|
||||
bulk_comments = []
|
||||
bulk_assets = {}
|
||||
|
||||
for comment in IssueComment.objects.all():
|
||||
# Parse the html
|
||||
soup = BeautifulSoup(comment.comment_html, "lxml")
|
||||
img_tags = soup.find_all("img")
|
||||
for img in img_tags:
|
||||
src = img.get("src", "")
|
||||
if src and (src.startswith(prefix1)):
|
||||
img["src"] = src[len(prefix1) :]
|
||||
file_assets[src[len(prefix1) :]] = {
|
||||
"project_id": str(comment.project_id),
|
||||
"comment_id": str(comment.id),
|
||||
}
|
||||
comment.comment_html = str(soup)
|
||||
bulk_comments.append(comment)
|
||||
|
||||
# prefix 2
|
||||
if not settings.USE_MINIO and src and src.startswith(prefix2):
|
||||
img["src"] = src[len(prefix2) :]
|
||||
file_assets[src[len(prefix2) :]] = {
|
||||
"project_id": str(comment.project_id),
|
||||
"comment_id": str(comment.id),
|
||||
}
|
||||
comment.comment_html = str(soup)
|
||||
bulk_comments.append(comment)
|
||||
|
||||
IssueComment.objects.bulk_update(
|
||||
bulk_comments, ["comment_html"], batch_size=1000
|
||||
)
|
||||
|
||||
# Update file assets
|
||||
FileAsset = apps.get_model("db", "FileAsset")
|
||||
bulk_assets = []
|
||||
for asset in FileAsset.objects.filter(asset__in=file_assets.keys()):
|
||||
asset.project_id = file_assets[str(asset.asset)]["project_id"]
|
||||
asset.entity_identifier = file_assets[str(asset.asset)]["comment_id"]
|
||||
asset.entity_type = "comment"
|
||||
bulk_assets.append(asset)
|
||||
|
||||
FileAsset.objects.bulk_update(
|
||||
bulk_assets,
|
||||
[
|
||||
"project_id",
|
||||
"entity_identifier",
|
||||
"entity_type",
|
||||
],
|
||||
batch_size=100,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("db", "0063_auto_20240321_0913"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(convert_issue_description_image_sources),
|
||||
migrations.RunPython(convert_page_image_sources),
|
||||
migrations.RunPython(convert_comment_image_sources),
|
||||
]
|
||||
43
apiserver/plane/db/migrations/0065_auto_20240321_0917.py
Normal file
43
apiserver/plane/db/migrations/0065_auto_20240321_0917.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 4.2.10 on 2024-03-21 09:17
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def create_attachment_assets(apps, schema_editor):
|
||||
bulk_assets = []
|
||||
|
||||
issue_attachments = {}
|
||||
|
||||
FileAsset = apps.get_model("db", "FileAsset")
|
||||
IssueAttachment = apps.get_model("db", "IssueAttachment")
|
||||
|
||||
for issue_attachment in IssueAttachment.objects.values():
|
||||
bulk_assets.append(
|
||||
FileAsset(
|
||||
workspace_id=issue_attachment["workspace_id"],
|
||||
project_id=issue_attachment["project_id"],
|
||||
entity_identifier=issue_attachment["issue_id"],
|
||||
entity_type="issue_attachment",
|
||||
asset=issue_attachment["asset"],
|
||||
attributes=issue_attachment["attributes"],
|
||||
)
|
||||
)
|
||||
issue_attachments[str(issue_attachment["asset"])] = str(
|
||||
issue_attachment["id"]
|
||||
)
|
||||
|
||||
FileAsset.objects.bulk_create(bulk_assets, batch_size=100)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("db", "0064_auto_20240321_0915"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_attachment_assets),
|
||||
migrations.DeleteModel(
|
||||
name="IssueAttachment",
|
||||
),
|
||||
]
|
||||
@@ -37,7 +37,6 @@ from .issue import (
|
||||
IssueMention,
|
||||
IssueLink,
|
||||
IssueSequence,
|
||||
IssueAttachment,
|
||||
IssueSubscriber,
|
||||
IssueReaction,
|
||||
CommentReaction,
|
||||
|
||||
@@ -2,18 +2,20 @@
|
||||
from uuid import uuid4
|
||||
|
||||
# Django import
|
||||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
|
||||
# Module import
|
||||
from . import BaseModel
|
||||
from plane.settings.storage import S3PrivateBucketStorage
|
||||
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
def get_upload_path(instance, filename):
|
||||
if instance.workspace_id is not None:
|
||||
return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
|
||||
return f"user-{uuid4().hex}-{filename}"
|
||||
return f"{instance.workspace.id}/{uuid4().hex}"
|
||||
return f"user-{uuid4().hex}"
|
||||
|
||||
|
||||
def file_size(value):
|
||||
@@ -32,6 +34,7 @@ class FileAsset(BaseModel):
|
||||
validators=[
|
||||
file_size,
|
||||
],
|
||||
storage=S3PrivateBucketStorage(),
|
||||
)
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace",
|
||||
@@ -39,7 +42,24 @@ class FileAsset(BaseModel):
|
||||
null=True,
|
||||
related_name="assets",
|
||||
)
|
||||
project = models.ForeignKey(
|
||||
"db.Project",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
related_name="assets",
|
||||
)
|
||||
entity_type = models.CharField(
|
||||
choices=(
|
||||
("issue_attachment", "Issue Attachment"),
|
||||
("issue_description", "Issue Description"),
|
||||
("comment", "Comment"),
|
||||
("page", "Page"),
|
||||
),
|
||||
null=True,
|
||||
)
|
||||
entity_identifier = models.UUIDField(null=True)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
size = models.PositiveBigIntegerField(null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "File Asset"
|
||||
@@ -49,3 +69,7 @@ class FileAsset(BaseModel):
|
||||
|
||||
def __str__(self):
|
||||
return str(self.asset)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.size = self.asset.size
|
||||
super(FileAsset, self).save(*args, **kwargs)
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
# Python import
|
||||
from uuid import uuid4
|
||||
# Python imports
|
||||
import uuid
|
||||
|
||||
# Django imports
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from . import ProjectBaseModel
|
||||
from plane.utils.html_processor import strip_tags
|
||||
|
||||
from .project import ProjectBaseModel
|
||||
|
||||
|
||||
def get_default_properties():
|
||||
return {
|
||||
@@ -96,6 +96,17 @@ class IssueManager(models.Manager):
|
||||
)
|
||||
|
||||
|
||||
def get_upload_path(instance, filename):
|
||||
if instance.workspace_id is not None:
|
||||
return f"{instance.workspace.id}/{uuid.uuid4().hex}"
|
||||
return f"user-{uuid.uuid4().hex}"
|
||||
|
||||
|
||||
def file_size(value):
|
||||
if value.size > settings.FILE_SIZE_LIMIT:
|
||||
raise ValidationError("File too large. Size should not exceed 5 MB.")
|
||||
|
||||
|
||||
class Issue(ProjectBaseModel):
|
||||
PRIORITY_CHOICES = (
|
||||
("urgent", "Urgent"),
|
||||
@@ -337,38 +348,6 @@ class IssueLink(ProjectBaseModel):
|
||||
return f"{self.issue.name} {self.url}"
|
||||
|
||||
|
||||
def get_upload_path(instance, filename):
|
||||
return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
|
||||
|
||||
|
||||
def file_size(value):
|
||||
# File limit check is only for cloud hosted
|
||||
if value.size > settings.FILE_SIZE_LIMIT:
|
||||
raise ValidationError("File too large. Size should not exceed 5 MB.")
|
||||
|
||||
|
||||
class IssueAttachment(ProjectBaseModel):
|
||||
attributes = models.JSONField(default=dict)
|
||||
asset = models.FileField(
|
||||
upload_to=get_upload_path,
|
||||
validators=[
|
||||
file_size,
|
||||
],
|
||||
)
|
||||
issue = models.ForeignKey(
|
||||
"db.Issue", on_delete=models.CASCADE, related_name="issue_attachment"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue Attachment"
|
||||
verbose_name_plural = "Issue Attachments"
|
||||
db_table = "issue_attachments"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.issue.name} {self.asset}"
|
||||
|
||||
|
||||
class IssueActivity(ProjectBaseModel):
|
||||
issue = models.ForeignKey(
|
||||
Issue,
|
||||
@@ -390,9 +369,6 @@ class IssueActivity(ProjectBaseModel):
|
||||
)
|
||||
|
||||
comment = models.TextField(verbose_name="Comment", blank=True)
|
||||
attachments = ArrayField(
|
||||
models.URLField(), size=10, blank=True, default=list
|
||||
)
|
||||
issue_comment = models.ForeignKey(
|
||||
"db.IssueComment",
|
||||
on_delete=models.SET_NULL,
|
||||
@@ -424,9 +400,6 @@ class IssueComment(ProjectBaseModel):
|
||||
comment_stripped = models.TextField(verbose_name="Comment", blank=True)
|
||||
comment_json = models.JSONField(blank=True, default=dict)
|
||||
comment_html = models.TextField(blank=True, default="<p></p>")
|
||||
attachments = ArrayField(
|
||||
models.URLField(), size=10, blank=True, default=list
|
||||
)
|
||||
issue = models.ForeignKey(
|
||||
Issue, on_delete=models.CASCADE, related_name="issue_comments"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,13 +4,13 @@ import string
|
||||
import uuid
|
||||
|
||||
import pytz
|
||||
|
||||
# Django imports
|
||||
from django.contrib.auth.models import (
|
||||
AbstractBaseUser,
|
||||
PermissionsMixin,
|
||||
UserManager,
|
||||
)
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
@@ -44,7 +44,7 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
first_name = models.CharField(max_length=255, blank=True)
|
||||
last_name = models.CharField(max_length=255, blank=True)
|
||||
avatar = models.CharField(max_length=255, blank=True)
|
||||
cover_image = models.URLField(blank=True, null=True, max_length=800)
|
||||
cover_image = models.CharField(blank=True, null=True, max_length=800)
|
||||
|
||||
# tracking metrics
|
||||
date_joined = models.DateTimeField(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -228,14 +228,14 @@ STORAGES = {
|
||||
},
|
||||
}
|
||||
STORAGES["default"] = {
|
||||
"BACKEND": "storages.backends.s3boto3.S3Boto3Storage",
|
||||
"BACKEND": "plane.settings.storage.S3PrivateBucketStorage",
|
||||
}
|
||||
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key")
|
||||
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key")
|
||||
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads")
|
||||
AWS_REGION = os.environ.get("AWS_REGION", "")
|
||||
AWS_DEFAULT_ACL = "public-read"
|
||||
AWS_QUERYSTRING_AUTH = False
|
||||
AWS_QUERYSTRING_AUTH = True
|
||||
AWS_S3_FILE_OVERWRITE = False
|
||||
AWS_S3_ENDPOINT_URL = os.environ.get(
|
||||
"AWS_S3_ENDPOINT_URL", None
|
||||
|
||||
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
|
||||
@@ -3,3 +3,5 @@ from .user import UserLiteSerializer
|
||||
from .issue import LabelLiteSerializer, StateLiteSerializer
|
||||
|
||||
from .state import StateSerializer, StateLiteSerializer
|
||||
|
||||
from .asset import FileAssetSerializer
|
||||
15
apiserver/plane/space/serializer/asset.py
Normal file
15
apiserver/plane/space/serializer/asset.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from .base import BaseFileSerializer
|
||||
from plane.db.models import FileAsset
|
||||
|
||||
|
||||
class FileAssetSerializer(BaseFileSerializer):
|
||||
|
||||
class Meta:
|
||||
model = FileAsset
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
@@ -56,3 +56,22 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||
self.fields.pop(field_name)
|
||||
|
||||
return self.fields
|
||||
|
||||
|
||||
class BaseFileSerializer(DynamicBaseSerializer):
|
||||
|
||||
class Meta:
|
||||
abstract = True # Make this serializer abstract
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""
|
||||
Object instance -> Dict of primitive datatypes.
|
||||
"""
|
||||
response = super().to_representation(instance)
|
||||
response[
|
||||
"asset"
|
||||
] = (
|
||||
instance.asset.name
|
||||
) # Ensure 'asset' field is consistently serialized
|
||||
# Apply custom method to get download URL
|
||||
return response
|
||||
@@ -22,7 +22,6 @@ from plane.db.models import (
|
||||
CycleIssue,
|
||||
ModuleIssue,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
IssueReaction,
|
||||
CommentReaction,
|
||||
IssueVote,
|
||||
@@ -171,22 +170,6 @@ class IssueLinkSerializer(BaseSerializer):
|
||||
)
|
||||
return IssueLink.objects.create(**validated_data)
|
||||
|
||||
|
||||
class IssueAttachmentSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = IssueAttachment
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"workspace",
|
||||
"project",
|
||||
"issue",
|
||||
]
|
||||
|
||||
|
||||
class IssueReactionSerializer(BaseSerializer):
|
||||
actor_detail = UserLiteSerializer(read_only=True, source="actor")
|
||||
|
||||
@@ -218,7 +201,6 @@ class IssueSerializer(BaseSerializer):
|
||||
issue_cycle = IssueCycleDetailSerializer(read_only=True)
|
||||
issue_module = IssueModuleDetailSerializer(read_only=True)
|
||||
issue_link = IssueLinkSerializer(read_only=True, many=True)
|
||||
issue_attachment = IssueAttachmentSerializer(read_only=True, many=True)
|
||||
sub_issues_count = serializers.IntegerField(read_only=True)
|
||||
issue_reactions = IssueReactionSerializer(read_only=True, many=True)
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from django.urls import path
|
||||
|
||||
|
||||
from plane.space.views import (
|
||||
IssueRetrievePublicEndpoint,
|
||||
CommentAssetPublicEndpoint,
|
||||
CommentReactionPublicViewSet,
|
||||
IssueAttachmentPublicEndpoint,
|
||||
IssueCommentPublicViewSet,
|
||||
IssueReactionPublicViewSet,
|
||||
CommentReactionPublicViewSet,
|
||||
IssueRetrievePublicEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@@ -35,6 +36,26 @@ urlpatterns = [
|
||||
),
|
||||
name="issue-comments-project-board",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/",
|
||||
IssueAttachmentPublicEndpoint.as_view(),
|
||||
name="project-issue-attachments",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/<uuid:workspace_id>/<str:asset_key>/",
|
||||
IssueAttachmentPublicEndpoint.as_view(),
|
||||
name="project-issue-attachments",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/comments/<uuid:comment_id>/attachments/",
|
||||
CommentAssetPublicEndpoint.as_view(),
|
||||
name="issue-comments-project-board-attachments",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/comments/<uuid:comment_id>/attachments/<uuid:workspace_id>/<str:asset_key>/",
|
||||
CommentAssetPublicEndpoint.as_view(),
|
||||
name="issue-comments-project-board-attachments",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/reactions/",
|
||||
IssueReactionPublicViewSet.as_view(
|
||||
@@ -73,4 +94,24 @@ urlpatterns = [
|
||||
),
|
||||
name="comment-reactions-project-board",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/",
|
||||
IssueAttachmentPublicEndpoint.as_view(),
|
||||
name="project-issue-attachments",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/<uuid:workspace_id>/<str:asset_key>/",
|
||||
IssueAttachmentPublicEndpoint.as_view(),
|
||||
name="project-issue-attachments",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/comments/<uuid:comment_id>/attachments/",
|
||||
CommentAssetPublicEndpoint.as_view(),
|
||||
name="issue-comments-project-board-attachments",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/comments/<uuid:comment_id>/attachments/<uuid:workspace_id>/<str:asset_key>/",
|
||||
CommentAssetPublicEndpoint.as_view(),
|
||||
name="issue-comments-project-board-attachments",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -10,6 +10,8 @@ from .issue import (
|
||||
IssueVotePublicViewSet,
|
||||
IssueRetrievePublicEndpoint,
|
||||
ProjectIssuesPublicEndpoint,
|
||||
CommentAssetPublicEndpoint,
|
||||
IssueAttachmentPublicEndpoint,
|
||||
)
|
||||
|
||||
from .inbox import InboxIssuePublicViewSet
|
||||
|
||||
@@ -2,32 +2,32 @@
|
||||
import json
|
||||
|
||||
# Django import
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q, OuterRef, Func, F, Prefetch
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db.models import F, Func, OuterRef, Prefetch, Q
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from .base import BaseViewSet
|
||||
from plane.db.models import (
|
||||
InboxIssue,
|
||||
Issue,
|
||||
State,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
ProjectDeployBoard,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
IssueSerializer,
|
||||
InboxIssueSerializer,
|
||||
IssueCreateSerializer,
|
||||
IssueSerializer,
|
||||
IssueStateInboxSerializer,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.app.views.base import BaseViewSet
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.db.models import (
|
||||
FileAsset,
|
||||
InboxIssue,
|
||||
Issue,
|
||||
IssueLink,
|
||||
ProjectDeployBoard,
|
||||
State,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
class InboxIssuePublicViewSet(BaseViewSet):
|
||||
@@ -95,8 +95,9 @@ class InboxIssuePublicViewSet(BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
||||
@@ -1,56 +1,61 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db.models import (
|
||||
Case,
|
||||
CharField,
|
||||
Exists,
|
||||
F,
|
||||
Func,
|
||||
IntegerField,
|
||||
Max,
|
||||
OuterRef,
|
||||
Prefetch,
|
||||
Q,
|
||||
Value,
|
||||
When,
|
||||
)
|
||||
from django.http import HttpResponseRedirect
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import (
|
||||
Prefetch,
|
||||
OuterRef,
|
||||
Func,
|
||||
F,
|
||||
Q,
|
||||
Case,
|
||||
Value,
|
||||
CharField,
|
||||
When,
|
||||
Exists,
|
||||
Max,
|
||||
IntegerField,
|
||||
)
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from .base import BaseViewSet, BaseAPIView
|
||||
from plane.app.serializers import (
|
||||
IssueCommentSerializer,
|
||||
IssueReactionSerializer,
|
||||
CommentReactionSerializer,
|
||||
IssueVoteSerializer,
|
||||
FileAssetSerializer,
|
||||
IssueCommentSerializer,
|
||||
IssuePublicSerializer,
|
||||
IssueReactionSerializer,
|
||||
IssueVoteSerializer,
|
||||
)
|
||||
|
||||
from plane.app.views.base import BaseAPIView, BaseViewSet
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.db.models import (
|
||||
CommentReaction,
|
||||
FileAsset,
|
||||
Issue,
|
||||
IssueComment,
|
||||
Label,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
State,
|
||||
ProjectMember,
|
||||
IssueReaction,
|
||||
CommentReaction,
|
||||
ProjectDeployBoard,
|
||||
IssueVote,
|
||||
Label,
|
||||
ProjectDeployBoard,
|
||||
ProjectMember,
|
||||
ProjectPublicMember,
|
||||
State,
|
||||
Workspace,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.grouper import group_results
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.presigned_url_generator import generate_download_presigned_url
|
||||
|
||||
|
||||
class IssueCommentPublicViewSet(BaseViewSet):
|
||||
@@ -215,6 +220,182 @@ class IssueCommentPublicViewSet(BaseViewSet):
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class IssueAttachmentPublicEndpoint(BaseAPIView):
|
||||
def get_permissions(self):
|
||||
if self.action in ["get"]:
|
||||
self.permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
else:
|
||||
self.permission_classes = [
|
||||
IsAuthenticated,
|
||||
]
|
||||
|
||||
return super(IssueAttachmentPublicEndpoint, self).get_permissions()
|
||||
|
||||
parser_classes = (
|
||||
MultiPartParser,
|
||||
FormParser,
|
||||
JSONParser,
|
||||
)
|
||||
|
||||
def post(self, request, slug, project_id, issue_id):
|
||||
serializer = FileAssetSerializer(data=request.data)
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
workspace=workspace,
|
||||
project_id=project_id,
|
||||
entity_identifier=issue_id,
|
||||
)
|
||||
issue_activity.delay(
|
||||
type="attachment.activity.created",
|
||||
requested_data=None,
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=str(self.kwargs.get("issue_id", None)),
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=json.dumps(
|
||||
serializer.data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(
|
||||
self, request, slug, project_id, issue_id, workspace_id, asset_key
|
||||
):
|
||||
key = f"{workspace_id}/{asset_key}"
|
||||
asset = FileAsset.objects.get(
|
||||
asset=key,
|
||||
entity_identifier=issue_id,
|
||||
entity_type="issue_attachment",
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
asset.is_deleted = True
|
||||
asset.save()
|
||||
issue_activity.delay(
|
||||
type="attachment.activity.deleted",
|
||||
requested_data=None,
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=str(self.kwargs.get("issue_id", None)),
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def get(
|
||||
self,
|
||||
request,
|
||||
slug,
|
||||
project_id,
|
||||
issue_id,
|
||||
workspace_id=None,
|
||||
asset_key=None,
|
||||
):
|
||||
if workspace_id and asset_key:
|
||||
key = f"{workspace_id}/{asset_key}"
|
||||
url = generate_download_presigned_url(
|
||||
key=key,
|
||||
host=request.get_host(),
|
||||
scheme=request.scheme,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
# For listing
|
||||
issue_attachments = FileAsset.objects.filter(
|
||||
entity_type="issue_attachment",
|
||||
entity_identifier=issue_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
serializer = FileAssetSerializer(issue_attachments, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class CommentAssetPublicEndpoint(BaseAPIView):
|
||||
def get_permissions(self):
|
||||
if self.action in ["get"]:
|
||||
self.permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
else:
|
||||
self.permission_classes = [
|
||||
IsAuthenticated,
|
||||
]
|
||||
|
||||
return super(CommentAssetPublicEndpoint, self).get_permissions()
|
||||
|
||||
parser_classes = (
|
||||
MultiPartParser,
|
||||
FormParser,
|
||||
JSONParser,
|
||||
)
|
||||
|
||||
def post(self, request, slug, project_id, comment_id):
|
||||
serializer = FileAssetSerializer(data=request.data)
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
workspace=workspace,
|
||||
project_id=project_id,
|
||||
entity_type="comment",
|
||||
entity_identifier=comment_id,
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(
|
||||
self, request, slug, project_id, comment_id, workspace_id, asset_key
|
||||
):
|
||||
key = f"{workspace_id}/{asset_key}"
|
||||
asset = FileAsset.objects.get(
|
||||
asset=key,
|
||||
entity_identifier=comment_id,
|
||||
entity_type="comment",
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
asset.is_deleted = True
|
||||
asset.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def get(
|
||||
self,
|
||||
request,
|
||||
slug,
|
||||
project_id,
|
||||
comment_id,
|
||||
workspace_id=None,
|
||||
asset_key=None,
|
||||
):
|
||||
if workspace_id and asset_key:
|
||||
key = f"{workspace_id}/{asset_key}"
|
||||
url = generate_download_presigned_url(
|
||||
key=key,
|
||||
host=request.get_host(),
|
||||
scheme=request.scheme,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
# For listing
|
||||
comment_assets = FileAsset.objects.filter(
|
||||
entity_type="comment",
|
||||
entity_identifier=comment_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
serializer = FileAssetSerializer(comment_assets, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class IssueReactionPublicViewSet(BaseViewSet):
|
||||
serializer_class = IssueReactionSerializer
|
||||
model = IssueReaction
|
||||
@@ -570,8 +751,9 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type="issue_attachment",
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
||||
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
|
||||
@@ -24,16 +24,20 @@ http {
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_set_header Host ${dollar}host;
|
||||
proxy_set_header X-Real-IP ${dollar}remote_addr;
|
||||
proxy_set_header X-Forwarded-For ${dollar}proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto ${dollar}scheme;
|
||||
proxy_pass http://api:8000/api/;
|
||||
}
|
||||
|
||||
|
||||
location /spaces/ {
|
||||
rewrite ^/spaces/?$ /spaces/login break;
|
||||
proxy_pass http://space:4000/spaces/;
|
||||
}
|
||||
|
||||
location /${BUCKET_NAME}/ {
|
||||
proxy_pass http://plane-minio:9000/uploads/;
|
||||
proxy_pass http://plane-minio:9000/${BUCKET_NAME}/;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,20 @@ http {
|
||||
add_header Permissions-Policy "interest-cohort=()" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
add_header Host ${dollar}host;
|
||||
add_header X-Real-IP ${dollar}remote_addr;
|
||||
add_header X-Forwarded-For ${dollar}proxy_add_x_forwarded_for;
|
||||
add_header X-Forwarded-Proto ${dollar}scheme;
|
||||
|
||||
location / {
|
||||
proxy_pass http://web:3000/;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_set_header Host ${dollar}host;
|
||||
proxy_set_header X-Real-IP ${dollar}remote_addr;
|
||||
proxy_set_header X-Forwarded-For ${dollar}proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto ${dollar}scheme;
|
||||
proxy_pass http://api:8000/api/;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ interface CustomEditorProps {
|
||||
description_html: string;
|
||||
};
|
||||
deleteFile: DeleteImage;
|
||||
getAsset: any;
|
||||
cancelUploadImage?: () => any;
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||
setShouldShowAlert?: (showAlert: boolean) => void;
|
||||
@@ -37,6 +38,7 @@ export const useEditor = ({
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
cancelUploadImage,
|
||||
getAsset,
|
||||
editorProps = {},
|
||||
value,
|
||||
rerenderOnPropsChange,
|
||||
@@ -64,7 +66,8 @@ export const useEditor = ({
|
||||
},
|
||||
deleteFile,
|
||||
restoreFile,
|
||||
cancelUploadImage
|
||||
cancelUploadImage,
|
||||
getAsset
|
||||
),
|
||||
...extensions,
|
||||
],
|
||||
@@ -84,6 +87,7 @@ export const useEditor = ({
|
||||
[rerenderOnPropsChange]
|
||||
);
|
||||
|
||||
console.log("yoooooooooo", editor?.getHTML());
|
||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||
editorRef.current = editor;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
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";
|
||||
@@ -24,10 +24,10 @@ import { CustomQuoteExtension } from "src/ui/extensions/quote";
|
||||
import { DeleteImage } from "src/types/delete-image";
|
||||
import { IMentionSuggestion } from "src/types/mention-suggestion";
|
||||
import { RestoreImage } from "src/types/restore-image";
|
||||
import { CustomLinkExtension } from "src/ui/extensions/custom-link";
|
||||
import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline";
|
||||
import { CustomTypographyExtension } from "src/ui/extensions/typography";
|
||||
import { CustomLinkExtension } from "src/ui/extensions/custom-link";
|
||||
import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule";
|
||||
import { CustomTypographyExtension } from "src/ui/extensions/typography";
|
||||
|
||||
export const CoreEditorExtensions = (
|
||||
mentionConfig: {
|
||||
@@ -36,7 +36,8 @@ export const CoreEditorExtensions = (
|
||||
},
|
||||
deleteFile: DeleteImage,
|
||||
restoreFile: RestoreImage,
|
||||
cancelUploadImage?: () => any
|
||||
cancelUploadImage?: () => any,
|
||||
getAsset?: any
|
||||
) => [
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -27,6 +27,7 @@ export type IRichTextEditor = {
|
||||
id: string;
|
||||
description_html: string;
|
||||
};
|
||||
getAsset: any;
|
||||
customClassName?: string;
|
||||
editorContentCustomClassNames?: string;
|
||||
onChange?: (json: any, html: string) => void;
|
||||
@@ -59,6 +60,7 @@ const RichTextEditor = ({
|
||||
value,
|
||||
initialValue,
|
||||
uploadFile,
|
||||
getAsset,
|
||||
deleteFile,
|
||||
noBorder,
|
||||
cancelUploadImage,
|
||||
@@ -81,6 +83,7 @@ const RichTextEditor = ({
|
||||
|
||||
const editor = useEditor({
|
||||
onChange,
|
||||
getAsset,
|
||||
debouncedUpdatesEnabled,
|
||||
setIsSubmitting,
|
||||
setShouldShowAlert,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { ChangeEvent, FC, useCallback, useEffect, useState } from "react";
|
||||
import debounce from "lodash/debounce";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { RichReadOnlyEditor, RichTextEditor } from "@plane/rich-text-editor";
|
||||
import { TIssue } from "@plane/types";
|
||||
import debounce from "lodash/debounce";
|
||||
import { observer } from "mobx-react";
|
||||
import { ChangeEvent, FC, useCallback, useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// hooks
|
||||
import { Loader, TextArea } from "@plane/ui";
|
||||
import { useMention, useWorkspace } from "@/hooks/store";
|
||||
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
||||
import { Loader, TextArea } from "@plane/ui";
|
||||
// components
|
||||
// types
|
||||
import { FileService } from "@/services/file.service";
|
||||
|
||||
@@ -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