[WEB-310] dev: private bucket implementation (#5793)
* chore: migrations and backmigration to move attachments to file asset * chore: move attachments to file assets * chore: update migration file to include created by and updated by and size * chore: remove uninmport errors * chore: make size as float field * fix: file asset uploads * chore: asset uploads migration changes * chore: v2 assets endpoint * chore: remove unused imports * chore: issue attachments * chore: issue attachments * chore: workspace logo endpoints * chore: private bucket changes * chore: user asset endpoint * chore: add logo_url validation * chore: cover image urlk * chore: change asset max length * chore: pages endpoint * chore: store the storage_metadata only when none * chore: attachment asset apis * chore: update create private bucket * chore: make bucket private * chore: fix response of user uploads * fix: response of user uploads * fix: job to fix file asset uploads * fix: user asset endpoints * chore: avatar for user profile * chore: external apis user url endpoint * chore: upload workspace and user asset actions updated * chore: analytics endpoint * fix: analytics export * chore: avatar urls * chore: update user avatar instances * chore: avatar urls for assignees and creators * chore: bucket permission script * fix: all user avatr instances in the web app * chore: update project cover image logic * fix: issue attachment endpoint * chore: patch endpoint for issue attachment * chore: attachments * chore: change attachment storage class * chore: update issue attachment endpoints * fix: issue attachment * chore: update issue attachment implementation * chore: page asset endpoints * fix: web build errors * chore: attachments * chore: page asset urls * chore: comment and issue asset endpoints * chore: asset endpoints * chore: attachment endpoints * chore: bulk asset endpoint * chore: restore endpoint * chore: project assets endpoints * chore: asset url * chore: add delete asset endpoints * chore: fix asset upload endpoint * chore: update patch endpoints * chore: update patch endpoint * chore: update editor image handling * chore: asset restore endpoints * chore: avatar url for space assets * chore: space app assets migration * fix: space app urls * chore: space endpoints * fix: old editor images rendering logic * fix: issue archive and attachment activity * chore: asset deletes * chore: attachment delete * fix: issue attachment * fix: issue attachment get * chore: cover image url for projects * chore: remove duplicate py file * fix: url check function * chore: chore project cover asset delete * fix: migrations * chore: delete migration files * chore: update bucket * fix: build errors * chore: add asset url in intake attachment * chore: project cover fix * chore: update next.config * chore: delete old workspace logos * chore: workspace assets * chore: asset get for space * chore: update project modal * chore: remove unused imports * fix: space app editor helper * chore: update rich-text read-only editor * chore: create multiple column for entity identifiers * chore: update migrations * chore: remove entity identifier * fix: issue assets * chore: update maximum file size logic * chore: update editor max file size logic * fix: close modal after removing workspace logo * chore: update uploaded asstes' status post issue creation * chore: added file size limit to the space app * dev: add file size limit restriction on all endpoints * fix: remove old workspace logo and user avatar --------- Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
This commit is contained in:
parent
c9580ab794
commit
7e334203f1
241 changed files with 5326 additions and 2518 deletions
|
|
@ -5,11 +5,13 @@ import { observer } from "mobx-react";
|
|||
import { useTheme as useNextTheme } from "next-themes";
|
||||
import { LogOut, UserCog2, Palette } from "lucide-react";
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
// plane ui
|
||||
import { Avatar } from "@plane/ui";
|
||||
// hooks
|
||||
import { API_BASE_URL, cn } from "@/helpers/common.helper";
|
||||
import { useTheme, useUser } from "@/hooks/store";
|
||||
// helpers
|
||||
import { API_BASE_URL, cn } from "@/helpers/common.helper";
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
// hooks
|
||||
import { useTheme, useUser } from "@/hooks/store";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
|
||||
|
|
@ -122,7 +124,7 @@ export const SidebarDropdown = observer(() => {
|
|||
<Menu.Button className="grid place-items-center outline-none">
|
||||
<Avatar
|
||||
name={currentUser.display_name}
|
||||
src={currentUser.avatar ?? undefined}
|
||||
src={getFileURL(currentUser.avatar_url)}
|
||||
size={24}
|
||||
shape="square"
|
||||
className="!text-base"
|
||||
|
|
|
|||
15
admin/helpers/file.helper.ts
Normal file
15
admin/helpers/file.helper.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// helpers
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
import { checkURLValidity } from "@/helpers/string.helper";
|
||||
|
||||
/**
|
||||
* @description combine the file path with the base URL
|
||||
* @param {string} path
|
||||
* @returns {string} final URL with the base URL
|
||||
*/
|
||||
export const getFileURL = (path: string): string | undefined => {
|
||||
if (!path) return undefined;
|
||||
const isValidURL = checkURLValidity(path);
|
||||
if (isValidURL) return path;
|
||||
return `${API_BASE_URL}${path}`;
|
||||
};
|
||||
21
admin/helpers/string.helper.ts
Normal file
21
admin/helpers/string.helper.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* @description
|
||||
* This function test whether a URL is valid or not.
|
||||
*
|
||||
* It accepts URLs with or without the protocol.
|
||||
* @param {string} url
|
||||
* @returns {boolean}
|
||||
* @example
|
||||
* checkURLValidity("https://example.com") => true
|
||||
* checkURLValidity("example.com") => true
|
||||
* checkURLValidity("example") => false
|
||||
*/
|
||||
export const checkURLValidity = (url: string): boolean => {
|
||||
if (!url) return false;
|
||||
|
||||
// regex to support complex query parameters and fragments
|
||||
const urlPattern =
|
||||
/^(https?:\/\/)?((([a-z\d-]+\.)*[a-z\d-]+\.[a-z]{2,6})|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}))(:\d+)?(\/[\w.-]*)*(\?[^#\s]*)?(#[\w-]*)?$/i;
|
||||
|
||||
return urlPattern.test(url);
|
||||
};
|
||||
|
|
@ -5,7 +5,6 @@ from .issue import (
|
|||
IssueSerializer,
|
||||
LabelSerializer,
|
||||
IssueLinkSerializer,
|
||||
IssueAttachmentSerializer,
|
||||
IssueCommentSerializer,
|
||||
IssueAttachmentSerializer,
|
||||
IssueActivitySerializer,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from plane.db.models import (
|
|||
IssueType,
|
||||
IssueActivity,
|
||||
IssueAssignee,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
IssueComment,
|
||||
IssueLabel,
|
||||
IssueLink,
|
||||
|
|
@ -31,6 +31,7 @@ from .user import UserLiteSerializer
|
|||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import URLValidator
|
||||
|
||||
|
||||
class IssueSerializer(BaseSerializer):
|
||||
assignees = serializers.ListField(
|
||||
child=serializers.PrimaryKeyRelatedField(
|
||||
|
|
@ -359,7 +360,7 @@ class IssueLinkSerializer(BaseSerializer):
|
|||
|
||||
class IssueAttachmentSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = IssueAttachment
|
||||
model = FileAsset
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"id",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ class ProjectSerializer(BaseSerializer):
|
|||
sort_order = serializers.FloatField(read_only=True)
|
||||
member_role = serializers.IntegerField(read_only=True)
|
||||
is_deployed = serializers.BooleanField(read_only=True)
|
||||
cover_image_url = serializers.CharField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
|
|
@ -32,6 +33,7 @@ class ProjectSerializer(BaseSerializer):
|
|||
"created_by",
|
||||
"updated_by",
|
||||
"deleted_at",
|
||||
"cover_image_url",
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
|
|
@ -87,6 +89,8 @@ class ProjectSerializer(BaseSerializer):
|
|||
|
||||
|
||||
class ProjectLiteSerializer(BaseSerializer):
|
||||
cover_image_url = serializers.CharField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = [
|
||||
|
|
@ -97,5 +101,6 @@ class ProjectLiteSerializer(BaseSerializer):
|
|||
"icon_prop",
|
||||
"emoji",
|
||||
"description",
|
||||
"cover_image_url",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ class UserLiteSerializer(BaseSerializer):
|
|||
"last_name",
|
||||
"email",
|
||||
"avatar",
|
||||
"avatar_url",
|
||||
"display_name",
|
||||
"email",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -13,8 +13,12 @@ from django.db.models import (
|
|||
Q,
|
||||
Sum,
|
||||
FloatField,
|
||||
Case,
|
||||
When,
|
||||
Value,
|
||||
)
|
||||
from django.db.models.functions import Cast
|
||||
from django.db.models.functions import Cast, Concat
|
||||
from django.db import models
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
|
|
@ -32,7 +36,7 @@ from plane.db.models import (
|
|||
CycleIssue,
|
||||
Issue,
|
||||
Project,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
IssueLink,
|
||||
ProjectMember,
|
||||
UserFavorite,
|
||||
|
|
@ -641,8 +645,9 @@ class CycleIssueAPIEndpoint(BaseAPIView):
|
|||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
|
@ -883,7 +888,27 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
|||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.values("display_name", "assignee_id", "avatar")
|
||||
.annotate(
|
||||
avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(
|
||||
assignees__avatar_asset__isnull=True,
|
||||
then="assignees__avatar",
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar", "avatar_url")
|
||||
.annotate(
|
||||
total_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField())
|
||||
|
|
@ -920,7 +945,8 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
|||
if item["assignee_id"]
|
||||
else None
|
||||
),
|
||||
"avatar": item["avatar"],
|
||||
"avatar": item.get("avatar", None),
|
||||
"avatar_url": item.get("avatar_url", None),
|
||||
"total_estimates": item["total_estimates"],
|
||||
"completed_estimates": item["completed_estimates"],
|
||||
"pending_estimates": item["pending_estimates"],
|
||||
|
|
@ -998,7 +1024,27 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
|||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.values("display_name", "assignee_id", "avatar")
|
||||
.annotate(
|
||||
avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(
|
||||
assignees__avatar_asset__isnull=True,
|
||||
then="assignees__avatar",
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar_url")
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
|
|
@ -1037,7 +1083,8 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
|||
"assignee_id": (
|
||||
str(item["assignee_id"]) if item["assignee_id"] else None
|
||||
),
|
||||
"avatar": item["avatar"],
|
||||
"avatar": item.get("avatar", None),
|
||||
"avatar_url": item.get("avatar_url", None),
|
||||
"total_issues": item["total_issues"],
|
||||
"completed_issues": item["completed_issues"],
|
||||
"pending_issues": item["pending_issues"],
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ from plane.bgtasks.issue_activities_task import issue_activity
|
|||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueActivity,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
IssueComment,
|
||||
IssueLink,
|
||||
Label,
|
||||
|
|
@ -210,8 +210,9 @@ class IssueAPIEndpoint(BaseAPIView):
|
|||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
|
@ -1062,7 +1063,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
|||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
model = IssueAttachment
|
||||
model = FileAsset
|
||||
parser_classes = (MultiPartParser, FormParser)
|
||||
|
||||
def post(self, request, slug, project_id, issue_id):
|
||||
|
|
@ -1070,7 +1071,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
|||
if (
|
||||
request.data.get("external_id")
|
||||
and request.data.get("external_source")
|
||||
and IssueAttachment.objects.filter(
|
||||
and FileAsset.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
issue_id=issue_id,
|
||||
|
|
@ -1078,7 +1079,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
|||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
issue_attachment = IssueAttachment.objects.filter(
|
||||
issue_attachment = FileAsset.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
external_id=request.data.get("external_id"),
|
||||
|
|
@ -1112,7 +1113,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
|||
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 = FileAsset.objects.get(pk=pk)
|
||||
issue_attachment.asset.delete(save=False)
|
||||
issue_attachment.delete()
|
||||
issue_activity.delay(
|
||||
|
|
@ -1130,7 +1131,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
|||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def get(self, request, slug, project_id, issue_id):
|
||||
issue_attachments = IssueAttachment.objects.filter(
|
||||
issue_attachments = FileAsset.objects.filter(
|
||||
issue_id=issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
serializer = IssueAttachmentSerializer(issue_attachments, many=True)
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from plane.app.permissions import ProjectEntityPermission
|
|||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
IssueLink,
|
||||
Module,
|
||||
ModuleIssue,
|
||||
|
|
@ -393,8 +393,9 @@ class ModuleIssueAPIEndpoint(BaseAPIView):
|
|||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ class DynamicBaseSerializer(BaseSerializer):
|
|||
allowed.append(list(item.keys())[0])
|
||||
|
||||
for field in allowed:
|
||||
if field not in self.fields:
|
||||
from . import (
|
||||
WorkspaceLiteSerializer,
|
||||
ProjectLiteSerializer,
|
||||
|
|
@ -62,7 +61,6 @@ class DynamicBaseSerializer(BaseSerializer):
|
|||
IssueRelationSerializer,
|
||||
InboxIssueLiteSerializer,
|
||||
IssueReactionLiteSerializer,
|
||||
IssueAttachmentLiteSerializer,
|
||||
IssueLinkLiteSerializer,
|
||||
)
|
||||
|
||||
|
|
@ -86,11 +84,11 @@ class DynamicBaseSerializer(BaseSerializer):
|
|||
"issue_relation": IssueRelationSerializer,
|
||||
"issue_inbox": InboxIssueLiteSerializer,
|
||||
"issue_reactions": IssueReactionLiteSerializer,
|
||||
"issue_attachment": IssueAttachmentLiteSerializer,
|
||||
"issue_link": IssueLinkLiteSerializer,
|
||||
"sub_issues": IssueLiteSerializer,
|
||||
}
|
||||
|
||||
if field not in self.fields and field in expansion:
|
||||
self.fields[field] = expansion[field](
|
||||
many=(
|
||||
True
|
||||
|
|
@ -178,4 +176,29 @@ class DynamicBaseSerializer(BaseSerializer):
|
|||
instance, f"{expand}_id", None
|
||||
)
|
||||
|
||||
# Check if issue_attachments is in fields or expand
|
||||
if (
|
||||
"issue_attachments" in self.fields
|
||||
or "issue_attachments" in self.expand
|
||||
):
|
||||
# Import the model here to avoid circular imports
|
||||
from plane.db.models import FileAsset
|
||||
|
||||
issue_id = getattr(instance, "id", None)
|
||||
|
||||
if issue_id:
|
||||
# Fetch related issue_attachments
|
||||
issue_attachments = FileAsset.objects.filter(
|
||||
issue_id=issue_id,
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
# Serialize issue_attachments and add them to the response
|
||||
response["issue_attachments"] = (
|
||||
IssueAttachmentLiteSerializer(
|
||||
issue_attachments, many=True
|
||||
).data
|
||||
)
|
||||
else:
|
||||
response["issue_attachments"] = []
|
||||
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ from plane.db.models import (
|
|||
Module,
|
||||
ModuleIssue,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
IssueReaction,
|
||||
CommentReaction,
|
||||
IssueVote,
|
||||
|
|
@ -498,8 +498,11 @@ class IssueLinkLiteSerializer(BaseSerializer):
|
|||
|
||||
|
||||
class IssueAttachmentSerializer(BaseSerializer):
|
||||
|
||||
asset_url = serializers.CharField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = IssueAttachment
|
||||
model = FileAsset
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"created_by",
|
||||
|
|
@ -514,14 +517,15 @@ class IssueAttachmentSerializer(BaseSerializer):
|
|||
|
||||
class IssueAttachmentLiteSerializer(DynamicBaseSerializer):
|
||||
class Meta:
|
||||
model = IssueAttachment
|
||||
model = FileAsset
|
||||
fields = [
|
||||
"id",
|
||||
"asset",
|
||||
"attributes",
|
||||
"issue_id",
|
||||
# "issue_id",
|
||||
"updated_at",
|
||||
"updated_by",
|
||||
"asset_url",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ class ProjectLiteSerializer(BaseSerializer):
|
|||
"identifier",
|
||||
"name",
|
||||
"cover_image",
|
||||
"cover_image_url",
|
||||
"logo_props",
|
||||
"description",
|
||||
]
|
||||
|
|
@ -117,6 +118,7 @@ class ProjectListSerializer(DynamicBaseSerializer):
|
|||
member_role = serializers.IntegerField(read_only=True)
|
||||
anchor = serializers.CharField(read_only=True)
|
||||
members = serializers.SerializerMethodField()
|
||||
cover_image_url = serializers.CharField(read_only=True)
|
||||
|
||||
def get_members(self, obj):
|
||||
project_members = getattr(obj, "members_list", None)
|
||||
|
|
@ -128,6 +130,7 @@ class ProjectListSerializer(DynamicBaseSerializer):
|
|||
"member_id": member.member_id,
|
||||
"member__display_name": member.member.display_name,
|
||||
"member__avatar": member.member.avatar,
|
||||
"member__avatar_url": member.member.avatar_url,
|
||||
}
|
||||
for member in project_members
|
||||
]
|
||||
|
|
|
|||
|
|
@ -56,12 +56,15 @@ class UserSerializer(BaseSerializer):
|
|||
|
||||
|
||||
class UserMeSerializer(BaseSerializer):
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"id",
|
||||
"avatar",
|
||||
"cover_image",
|
||||
"avatar_url",
|
||||
"cover_image_url",
|
||||
"date_joined",
|
||||
"display_name",
|
||||
"email",
|
||||
|
|
@ -156,6 +159,7 @@ class UserLiteSerializer(BaseSerializer):
|
|||
"first_name",
|
||||
"last_name",
|
||||
"avatar",
|
||||
"avatar_url",
|
||||
"is_bot",
|
||||
"display_name",
|
||||
]
|
||||
|
|
@ -173,6 +177,7 @@ class UserAdminLiteSerializer(BaseSerializer):
|
|||
"first_name",
|
||||
"last_name",
|
||||
"avatar",
|
||||
"avatar_url",
|
||||
"is_bot",
|
||||
"display_name",
|
||||
"email",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ class WorkSpaceSerializer(DynamicBaseSerializer):
|
|||
owner = UserLiteSerializer(read_only=True)
|
||||
total_members = serializers.IntegerField(read_only=True)
|
||||
total_issues = serializers.IntegerField(read_only=True)
|
||||
logo_url = serializers.CharField(read_only=True)
|
||||
|
||||
def validate_slug(self, value):
|
||||
# Check if the slug is restricted
|
||||
|
|
@ -39,6 +40,7 @@ class WorkSpaceSerializer(DynamicBaseSerializer):
|
|||
"created_at",
|
||||
"updated_at",
|
||||
"owner",
|
||||
"logo_url",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@ from plane.app.views import (
|
|||
FileAssetEndpoint,
|
||||
UserAssetsEndpoint,
|
||||
FileAssetViewSet,
|
||||
# V2 Endpoints
|
||||
WorkspaceFileAssetEndpoint,
|
||||
UserAssetsV2Endpoint,
|
||||
StaticFileAssetEndpoint,
|
||||
AssetRestoreEndpoint,
|
||||
ProjectAssetEndpoint,
|
||||
ProjectBulkAssetEndpoint,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -38,4 +45,49 @@ urlpatterns = [
|
|||
),
|
||||
name="file-assets-restore",
|
||||
),
|
||||
# V2 Endpoints
|
||||
path(
|
||||
"assets/v2/workspaces/<str:slug>/",
|
||||
WorkspaceFileAssetEndpoint.as_view(),
|
||||
name="workspace-file-assets",
|
||||
),
|
||||
path(
|
||||
"assets/v2/workspaces/<str:slug>/<uuid:asset_id>/",
|
||||
WorkspaceFileAssetEndpoint.as_view(),
|
||||
name="workspace-file-assets",
|
||||
),
|
||||
path(
|
||||
"assets/v2/user-assets/",
|
||||
UserAssetsV2Endpoint.as_view(),
|
||||
name="user-file-assets",
|
||||
),
|
||||
path(
|
||||
"assets/v2/user-assets/<uuid:asset_id>/",
|
||||
UserAssetsV2Endpoint.as_view(),
|
||||
name="user-file-assets",
|
||||
),
|
||||
path(
|
||||
"assets/v2/workspaces/<str:slug>/restore/<uuid:asset_id>/",
|
||||
AssetRestoreEndpoint.as_view(),
|
||||
name="asset-restore",
|
||||
),
|
||||
path(
|
||||
"assets/v2/static/<uuid:asset_id>/",
|
||||
StaticFileAssetEndpoint.as_view(),
|
||||
name="static-file-asset",
|
||||
),
|
||||
path(
|
||||
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/",
|
||||
ProjectAssetEndpoint.as_view(),
|
||||
name="bulk-asset-update",
|
||||
),
|
||||
path(
|
||||
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/<uuid:pk>/",
|
||||
ProjectAssetEndpoint.as_view(),
|
||||
name="bulk-asset-update",
|
||||
),
|
||||
path(
|
||||
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/<uuid:entity_id>/bulk/",
|
||||
ProjectBulkAssetEndpoint.as_view(),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from plane.app.views import (
|
|||
BulkArchiveIssuesEndpoint,
|
||||
DeletedIssuesListViewSet,
|
||||
IssuePaginatedViewSet,
|
||||
IssueAttachmentV2Endpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
|
|
@ -132,6 +133,18 @@ urlpatterns = [
|
|||
IssueAttachmentEndpoint.as_view(),
|
||||
name="project-issue-attachments",
|
||||
),
|
||||
# V2 Attachments
|
||||
path(
|
||||
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/",
|
||||
IssueAttachmentV2Endpoint.as_view(),
|
||||
name="project-issue-attachments",
|
||||
),
|
||||
path(
|
||||
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/<uuid:pk>/",
|
||||
IssueAttachmentV2Endpoint.as_view(),
|
||||
name="project-issue-attachments",
|
||||
),
|
||||
## Export Issues
|
||||
path(
|
||||
"workspaces/<str:slug>/export-issues/",
|
||||
ExportIssuesEndpoint.as_view(),
|
||||
|
|
|
|||
|
|
@ -110,7 +110,19 @@ from .cycle.archive import (
|
|||
CycleArchiveUnarchiveEndpoint,
|
||||
)
|
||||
|
||||
from .asset.base import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
|
||||
from .asset.base import (
|
||||
FileAssetEndpoint,
|
||||
UserAssetsEndpoint,
|
||||
FileAssetViewSet,
|
||||
)
|
||||
from .asset.v2 import (
|
||||
WorkspaceFileAssetEndpoint,
|
||||
UserAssetsV2Endpoint,
|
||||
StaticFileAssetEndpoint,
|
||||
AssetRestoreEndpoint,
|
||||
ProjectAssetEndpoint,
|
||||
ProjectBulkAssetEndpoint,
|
||||
)
|
||||
from .issue.base import (
|
||||
IssueListEndpoint,
|
||||
IssueViewSet,
|
||||
|
|
@ -128,6 +140,8 @@ from .issue.archive import IssueArchiveViewSet, BulkArchiveIssuesEndpoint
|
|||
|
||||
from .issue.attachment import (
|
||||
IssueAttachmentEndpoint,
|
||||
# V2
|
||||
IssueAttachmentV2Endpoint,
|
||||
)
|
||||
|
||||
from .issue.comment import (
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@
|
|||
from django.db.models import Count, F, Sum
|
||||
from django.db.models.functions import ExtractMonth
|
||||
from django.utils import timezone
|
||||
from django.db.models.functions import Concat
|
||||
from django.db.models import Case, When, Value
|
||||
from django.db import models
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
|
|
@ -120,12 +123,12 @@ class AnalyticsEndpoint(BaseAPIView):
|
|||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
**filters,
|
||||
assignees__avatar__isnull=False,
|
||||
assignees__avatar_url__isnull=False,
|
||||
)
|
||||
.order_by("assignees__id")
|
||||
.distinct("assignees__id")
|
||||
.values(
|
||||
"assignees__avatar",
|
||||
"assignees__avatar_url",
|
||||
"assignees__display_name",
|
||||
"assignees__first_name",
|
||||
"assignees__last_name",
|
||||
|
|
@ -355,7 +358,6 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
|
|||
user_details = [
|
||||
"created_by__first_name",
|
||||
"created_by__last_name",
|
||||
"created_by__avatar",
|
||||
"created_by__display_name",
|
||||
"created_by__id",
|
||||
]
|
||||
|
|
@ -364,13 +366,32 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
|
|||
base_issues.exclude(created_by=None)
|
||||
.values(*user_details)
|
||||
.annotate(count=Count("id"))
|
||||
.annotate(
|
||||
created_by__avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
created_by__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"created_by__avatar_asset", # Assuming avatar_asset has an id or relevant field
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(
|
||||
created_by__avatar_asset__isnull=True,
|
||||
then="created_by__avatar",
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.order_by("-count")[:5]
|
||||
)
|
||||
|
||||
user_assignee_details = [
|
||||
"assignees__first_name",
|
||||
"assignees__last_name",
|
||||
"assignees__avatar",
|
||||
"assignees__display_name",
|
||||
"assignees__id",
|
||||
]
|
||||
|
|
@ -379,6 +400,26 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
|
|||
base_issues.filter(completed_at__isnull=False)
|
||||
.exclude(assignees=None)
|
||||
.values(*user_assignee_details)
|
||||
.annotate(
|
||||
assignees__avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(
|
||||
assignees__avatar_asset__isnull=True,
|
||||
then="assignees__avatar",
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")[:5]
|
||||
)
|
||||
|
|
@ -387,6 +428,26 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
|
|||
base_issues.filter(completed_at__isnull=True)
|
||||
.values(*user_assignee_details)
|
||||
.annotate(count=Count("id"))
|
||||
.annotate(
|
||||
assignees__avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(
|
||||
assignees__avatar_asset__isnull=True,
|
||||
then="assignees__avatar",
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.order_by("-count")
|
||||
)
|
||||
|
||||
|
|
|
|||
691
apiserver/plane/app/views/asset/v2.py
Normal file
691
apiserver/plane/app/views/asset/v2.py
Normal file
|
|
@ -0,0 +1,691 @@
|
|||
# Python imports
|
||||
import uuid
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
# Module imports
|
||||
from ..base import BaseAPIView
|
||||
from plane.db.models import (
|
||||
FileAsset,
|
||||
Workspace,
|
||||
Project,
|
||||
User,
|
||||
)
|
||||
from plane.settings.storage import S3Storage
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
|
||||
|
||||
class UserAssetsV2Endpoint(BaseAPIView):
|
||||
"""This endpoint is used to upload user profile images."""
|
||||
|
||||
def asset_delete(self, asset_id):
|
||||
asset = FileAsset.objects.filter(id=asset_id).first()
|
||||
if asset is None:
|
||||
return
|
||||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
asset.save()
|
||||
return
|
||||
|
||||
def entity_asset_save(self, asset_id, entity_type, asset):
|
||||
# User Avatar
|
||||
if entity_type == FileAsset.EntityTypeContext.USER_AVATAR:
|
||||
user = User.objects.get(id=asset.user_id)
|
||||
user.avatar = ""
|
||||
# Delete the previous avatar
|
||||
if user.avatar_asset_id:
|
||||
self.asset_delete(user.avatar_asset_id)
|
||||
# Save the new avatar
|
||||
user.avatar_asset_id = asset_id
|
||||
user.save()
|
||||
return
|
||||
# User Cover
|
||||
if entity_type == FileAsset.EntityTypeContext.USER_COVER:
|
||||
user = User.objects.get(id=asset.user_id)
|
||||
user.cover_image = None
|
||||
# Delete the previous cover image
|
||||
if user.cover_image_asset_id:
|
||||
self.asset_delete(user.cover_image_asset_id)
|
||||
# Save the new cover image
|
||||
user.cover_image_asset_id = asset_id
|
||||
user.save()
|
||||
return
|
||||
return
|
||||
|
||||
def entity_asset_delete(self, entity_type, asset):
|
||||
# User Avatar
|
||||
if entity_type == FileAsset.EntityTypeContext.USER_AVATAR:
|
||||
user = User.objects.get(id=asset.user_id)
|
||||
user.avatar_asset_id = None
|
||||
user.save()
|
||||
return
|
||||
# User Cover
|
||||
if entity_type == FileAsset.EntityTypeContext.USER_COVER:
|
||||
user = User.objects.get(id=asset.user_id)
|
||||
user.cover_image_asset_id = None
|
||||
user.save()
|
||||
return
|
||||
return
|
||||
|
||||
def post(self, request):
|
||||
# get the asset key
|
||||
name = request.data.get("name")
|
||||
type = request.data.get("type", "image/jpeg")
|
||||
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
|
||||
entity_type = request.data.get("entity_type", False)
|
||||
|
||||
# Check if the entity type is allowed
|
||||
if not entity_type or entity_type not in ["USER_AVATAR", "USER_COVER"]:
|
||||
return Response(
|
||||
{
|
||||
"error": "Invalid entity type.",
|
||||
"status": False,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if the file type is allowed
|
||||
allowed_types = ["image/jpeg", "image/png", "image/webp", "image/jpg"]
|
||||
if type not in allowed_types:
|
||||
return Response(
|
||||
{
|
||||
"error": "Invalid file type. Only JPEG and PNG files are allowed.",
|
||||
"status": False,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get the size limit
|
||||
size_limit = min(settings.FILE_SIZE_LIMIT, size)
|
||||
|
||||
# asset key
|
||||
asset_key = f"{uuid.uuid4().hex}-{name}"
|
||||
|
||||
# Create a File Asset
|
||||
asset = FileAsset.objects.create(
|
||||
attributes={
|
||||
"name": name,
|
||||
"type": type,
|
||||
"size": size_limit,
|
||||
},
|
||||
asset=asset_key,
|
||||
size=size_limit,
|
||||
user=request.user,
|
||||
created_by=request.user,
|
||||
entity_type=entity_type,
|
||||
)
|
||||
|
||||
# Get the presigned URL
|
||||
storage = S3Storage(request=request)
|
||||
# Generate a presigned URL to share an S3 object
|
||||
presigned_url = storage.generate_presigned_post(
|
||||
object_name=asset_key,
|
||||
file_type=type,
|
||||
file_size=size_limit,
|
||||
)
|
||||
# Return the presigned URL
|
||||
return Response(
|
||||
{
|
||||
"upload_data": presigned_url,
|
||||
"asset_id": str(asset.id),
|
||||
"asset_url": asset.asset_url,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def patch(self, request, asset_id):
|
||||
# get the asset id
|
||||
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
|
||||
storage = S3Storage(request=request)
|
||||
# get the storage metadata
|
||||
asset.is_uploaded = True
|
||||
# get the storage metadata
|
||||
if asset.storage_metadata is None:
|
||||
asset.storage_metadata = storage.get_object_metadata(
|
||||
object_name=asset.asset.name
|
||||
)
|
||||
# get the entity and save the asset id for the request field
|
||||
self.entity_asset_save(asset_id, asset.entity_type, asset)
|
||||
# update the attributes
|
||||
asset.attributes = request.data.get("attributes", asset.attributes)
|
||||
# save the asset
|
||||
asset.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def delete(self, request, asset_id):
|
||||
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
|
||||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
# get the entity and save the asset id for the request field
|
||||
self.entity_asset_delete(asset.entity_type, asset)
|
||||
asset.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class WorkspaceFileAssetEndpoint(BaseAPIView):
|
||||
"""This endpoint is used to upload cover images/logos etc for workspace, projects and users."""
|
||||
|
||||
def get_entity_id_field(self, entity_type, entity_id):
|
||||
if entity_type == FileAsset.EntityTypeContext.WORKSPACE_LOGO:
|
||||
return {
|
||||
"workspace_id": entity_id,
|
||||
}
|
||||
|
||||
if entity_type == FileAsset.EntityTypeContext.PROJECT_COVER:
|
||||
return {
|
||||
"project_id": entity_id,
|
||||
}
|
||||
|
||||
if entity_type in [
|
||||
FileAsset.EntityTypeContext.USER_AVATAR,
|
||||
FileAsset.EntityTypeContext.USER_COVER,
|
||||
]:
|
||||
return {
|
||||
"user_id": entity_id,
|
||||
}
|
||||
|
||||
if entity_type in [
|
||||
FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
FileAsset.EntityTypeContext.ISSUE_DESCRIPTION,
|
||||
]:
|
||||
return {
|
||||
"issue_id": entity_id,
|
||||
}
|
||||
|
||||
if entity_type == FileAsset.EntityTypeContext.PAGE_DESCRIPTION:
|
||||
return {
|
||||
"page_id": entity_id,
|
||||
}
|
||||
|
||||
if entity_type == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION:
|
||||
return {
|
||||
"comment_id": entity_id,
|
||||
}
|
||||
return {}
|
||||
|
||||
def asset_delete(self, asset_id):
|
||||
asset = FileAsset.objects.filter(id=asset_id).first()
|
||||
# Check if the asset exists
|
||||
if asset is None:
|
||||
return
|
||||
# Mark the asset as deleted
|
||||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
asset.save()
|
||||
return
|
||||
|
||||
def entity_asset_save(self, asset_id, entity_type, asset):
|
||||
# Workspace Logo
|
||||
if entity_type == FileAsset.EntityTypeContext.WORKSPACE_LOGO:
|
||||
workspace = Workspace.objects.filter(id=asset.workspace_id).first()
|
||||
if workspace is None:
|
||||
return
|
||||
# Delete the previous logo
|
||||
if workspace.logo_asset_id:
|
||||
self.asset_delete(workspace.logo_asset_id)
|
||||
# Save the new logo
|
||||
workspace.logo = ""
|
||||
workspace.logo_asset_id = asset_id
|
||||
workspace.save()
|
||||
return
|
||||
|
||||
# Project Cover
|
||||
elif entity_type == FileAsset.EntityTypeContext.PROJECT_COVER:
|
||||
project = Project.objects.filter(id=asset.workspace_id).first()
|
||||
if project is None:
|
||||
return
|
||||
# Delete the previous cover image
|
||||
if project.cover_image_asset_id:
|
||||
self.asset_delete(project.cover_image_asset_id)
|
||||
# Save the new cover image
|
||||
project.cover_image = ""
|
||||
project.cover_image_asset_id = asset_id
|
||||
project.save()
|
||||
return
|
||||
else:
|
||||
return
|
||||
|
||||
def entity_asset_delete(self, entity_type, asset):
|
||||
# Workspace Logo
|
||||
if entity_type == FileAsset.EntityTypeContext.WORKSPACE_LOGO:
|
||||
workspace = Workspace.objects.get(id=asset.workspace_id)
|
||||
if workspace is None:
|
||||
return
|
||||
workspace.logo_asset_id = None
|
||||
workspace.save()
|
||||
return
|
||||
# Project Cover
|
||||
elif entity_type == FileAsset.EntityTypeContext.PROJECT_COVER:
|
||||
project = Project.objects.filter(id=asset.project_id).first()
|
||||
if project is None:
|
||||
return
|
||||
project.cover_image_asset_id = None
|
||||
project.save()
|
||||
return
|
||||
else:
|
||||
return
|
||||
|
||||
def post(self, request, slug):
|
||||
name = request.data.get("name")
|
||||
type = request.data.get("type", "image/jpeg")
|
||||
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
|
||||
entity_type = request.data.get("entity_type")
|
||||
entity_identifier = request.data.get("entity_identifier", False)
|
||||
|
||||
# Check if the entity type is allowed
|
||||
if entity_type not in FileAsset.EntityTypeContext.values:
|
||||
return Response(
|
||||
{
|
||||
"error": "Invalid entity type.",
|
||||
"status": False,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if the file type is allowed
|
||||
allowed_types = ["image/jpeg", "image/png", "image/webp", "image/jpg"]
|
||||
if type not in allowed_types:
|
||||
return Response(
|
||||
{
|
||||
"error": "Invalid file type. Only JPEG and PNG files are allowed.",
|
||||
"status": False,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get the size limit
|
||||
size_limit = min(settings.FILE_SIZE_LIMIT, size)
|
||||
|
||||
# Get the workspace
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
# asset key
|
||||
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
|
||||
|
||||
# Create a File Asset
|
||||
asset = FileAsset.objects.create(
|
||||
attributes={
|
||||
"name": name,
|
||||
"type": type,
|
||||
"size": size_limit,
|
||||
},
|
||||
asset=asset_key,
|
||||
size=size_limit,
|
||||
workspace=workspace,
|
||||
created_by=request.user,
|
||||
entity_type=entity_type,
|
||||
**self.get_entity_id_field(entity_type, entity_identifier),
|
||||
)
|
||||
|
||||
# Get the presigned URL
|
||||
storage = S3Storage(request=request)
|
||||
# Generate a presigned URL to share an S3 object
|
||||
presigned_url = storage.generate_presigned_post(
|
||||
object_name=asset_key,
|
||||
file_type=type,
|
||||
file_size=size_limit,
|
||||
)
|
||||
# Return the presigned URL
|
||||
return Response(
|
||||
{
|
||||
"upload_data": presigned_url,
|
||||
"asset_id": str(asset.id),
|
||||
"asset_url": asset.asset_url,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def patch(self, request, slug, asset_id):
|
||||
# get the asset id
|
||||
asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug)
|
||||
storage = S3Storage(request=request)
|
||||
# get the storage metadata
|
||||
asset.is_uploaded = True
|
||||
# get the storage metadata
|
||||
if asset.storage_metadata is None:
|
||||
asset.storage_metadata = storage.get_object_metadata(
|
||||
object_name=asset.asset.name
|
||||
)
|
||||
# get the entity and save the asset id for the request field
|
||||
self.entity_asset_save(asset_id, asset.entity_type, asset)
|
||||
# update the attributes
|
||||
asset.attributes = request.data.get("attributes", asset.attributes)
|
||||
# save the asset
|
||||
asset.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def delete(self, request, slug, asset_id):
|
||||
asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug)
|
||||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
# get the entity and save the asset id for the request field
|
||||
self.entity_asset_delete(asset.entity_type, asset)
|
||||
asset.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def get(self, request, slug, asset_id):
|
||||
# get the asset id
|
||||
asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug)
|
||||
|
||||
# Check if the asset is uploaded
|
||||
if not asset.is_uploaded:
|
||||
return Response(
|
||||
{
|
||||
"error": "The requested asset could not be found.",
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
# Get the presigned URL
|
||||
storage = S3Storage(request=request)
|
||||
# Generate a presigned URL to share an S3 object
|
||||
signed_url = storage.generate_presigned_url(
|
||||
object_name=asset.asset.name,
|
||||
)
|
||||
# Redirect to the signed URL
|
||||
return HttpResponseRedirect(signed_url)
|
||||
|
||||
|
||||
class StaticFileAssetEndpoint(BaseAPIView):
|
||||
"""This endpoint is used to get the signed URL for a static asset."""
|
||||
|
||||
permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
|
||||
def get(self, request, asset_id):
|
||||
# get the asset id
|
||||
asset = FileAsset.objects.get(id=asset_id)
|
||||
|
||||
# Check if the asset is uploaded
|
||||
if not asset.is_uploaded:
|
||||
return Response(
|
||||
{
|
||||
"error": "The requested asset could not be found.",
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
# Check if the entity type is allowed
|
||||
if asset.entity_type not in [
|
||||
FileAsset.EntityTypeContext.USER_AVATAR,
|
||||
FileAsset.EntityTypeContext.USER_COVER,
|
||||
FileAsset.EntityTypeContext.WORKSPACE_LOGO,
|
||||
FileAsset.EntityTypeContext.PROJECT_COVER,
|
||||
]:
|
||||
return Response(
|
||||
{
|
||||
"error": "Invalid entity type.",
|
||||
"status": False,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get the presigned URL
|
||||
storage = S3Storage(request=request)
|
||||
# Generate a presigned URL to share an S3 object
|
||||
signed_url = storage.generate_presigned_url(
|
||||
object_name=asset.asset.name,
|
||||
)
|
||||
# Redirect to the signed URL
|
||||
return HttpResponseRedirect(signed_url)
|
||||
|
||||
|
||||
class AssetRestoreEndpoint(BaseAPIView):
|
||||
"""Endpoint to restore a deleted assets."""
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def post(self, request, slug, asset_id):
|
||||
asset = FileAsset.all_objects.get(id=asset_id, workspace__slug=slug)
|
||||
asset.is_deleted = False
|
||||
asset.deleted_at = None
|
||||
asset.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class ProjectAssetEndpoint(BaseAPIView):
|
||||
"""This endpoint is used to upload cover images/logos etc for workspace, projects and users."""
|
||||
|
||||
def get_entity_id_fiekd(self, entity_type, entity_id):
|
||||
if entity_type == FileAsset.EntityTypeContext.WORKSPACE_LOGO:
|
||||
return {
|
||||
"workspace_id": entity_id,
|
||||
}
|
||||
|
||||
if entity_type == FileAsset.EntityTypeContext.PROJECT_COVER:
|
||||
return {
|
||||
"project_id": entity_id,
|
||||
}
|
||||
|
||||
if entity_type in [
|
||||
FileAsset.EntityTypeContext.USER_AVATAR,
|
||||
FileAsset.EntityTypeContext.USER_COVER,
|
||||
]:
|
||||
return {
|
||||
"user_id": entity_id,
|
||||
}
|
||||
|
||||
if entity_type in [
|
||||
FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
FileAsset.EntityTypeContext.ISSUE_DESCRIPTION,
|
||||
]:
|
||||
return {
|
||||
"issue_id": entity_id,
|
||||
}
|
||||
|
||||
if entity_type == FileAsset.EntityTypeContext.PAGE_DESCRIPTION:
|
||||
return {
|
||||
"page_id": entity_id,
|
||||
}
|
||||
|
||||
if entity_type == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION:
|
||||
return {
|
||||
"comment_id": entity_id,
|
||||
}
|
||||
return {}
|
||||
|
||||
@allow_permission(
|
||||
[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST],
|
||||
)
|
||||
def post(self, request, slug, project_id):
|
||||
name = request.data.get("name")
|
||||
type = request.data.get("type", "image/jpeg")
|
||||
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
|
||||
entity_type = request.data.get("entity_type", "")
|
||||
entity_identifier = request.data.get("entity_identifier")
|
||||
|
||||
# Check if the entity type is allowed
|
||||
if entity_type not in FileAsset.EntityTypeContext.values:
|
||||
return Response(
|
||||
{
|
||||
"error": "Invalid entity type.",
|
||||
"status": False,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if the file type is allowed
|
||||
allowed_types = ["image/jpeg", "image/png", "image/webp"]
|
||||
if type not in allowed_types:
|
||||
return Response(
|
||||
{
|
||||
"error": "Invalid file type. Only JPEG and PNG files are allowed.",
|
||||
"status": False,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get the size limit
|
||||
size_limit = min(settings.FILE_SIZE_LIMIT, size)
|
||||
|
||||
# Get the workspace
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
# asset key
|
||||
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
|
||||
|
||||
# Create a File Asset
|
||||
asset = FileAsset.objects.create(
|
||||
attributes={
|
||||
"name": name,
|
||||
"type": type,
|
||||
"size": size_limit,
|
||||
},
|
||||
asset=asset_key,
|
||||
size=size_limit,
|
||||
workspace=workspace,
|
||||
created_by=request.user,
|
||||
entity_type=entity_type,
|
||||
project_id=project_id,
|
||||
**self.get_entity_id_fiekd(entity_type, entity_identifier),
|
||||
)
|
||||
|
||||
# Get the presigned URL
|
||||
storage = S3Storage(request=request)
|
||||
# Generate a presigned URL to share an S3 object
|
||||
presigned_url = storage.generate_presigned_post(
|
||||
object_name=asset_key,
|
||||
file_type=type,
|
||||
file_size=size_limit,
|
||||
)
|
||||
# Return the presigned URL
|
||||
return Response(
|
||||
{
|
||||
"upload_data": presigned_url,
|
||||
"asset_id": str(asset.id),
|
||||
"asset_url": asset.asset_url,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@allow_permission(
|
||||
[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST],
|
||||
)
|
||||
def patch(self, request, slug, project_id, pk):
|
||||
# get the asset id
|
||||
asset = FileAsset.objects.get(
|
||||
id=pk,
|
||||
)
|
||||
storage = S3Storage(request=request)
|
||||
# get the storage metadata
|
||||
asset.is_uploaded = True
|
||||
# get the storage metadata
|
||||
if asset.storage_metadata is None:
|
||||
asset.storage_metadata = storage.get_object_metadata(
|
||||
object_name=asset.asset.name
|
||||
)
|
||||
|
||||
# update the attributes
|
||||
asset.attributes = request.data.get("attributes", asset.attributes)
|
||||
# save the asset
|
||||
asset.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def delete(self, request, slug, project_id, pk):
|
||||
# Get the asset
|
||||
asset = FileAsset.objects.get(
|
||||
id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
# Check deleted assets
|
||||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
# Save the asset
|
||||
asset.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id, pk):
|
||||
# get the asset id
|
||||
asset = FileAsset.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
pk=pk,
|
||||
)
|
||||
|
||||
# Check if the asset is uploaded
|
||||
if not asset.is_uploaded:
|
||||
return Response(
|
||||
{
|
||||
"error": "The requested asset could not be found.",
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
# Get the presigned URL
|
||||
storage = S3Storage(request=request)
|
||||
# Generate a presigned URL to share an S3 object
|
||||
signed_url = storage.generate_presigned_url(
|
||||
object_name=asset.asset.name,
|
||||
)
|
||||
# Redirect to the signed URL
|
||||
return HttpResponseRedirect(signed_url)
|
||||
|
||||
|
||||
class ProjectBulkAssetEndpoint(BaseAPIView):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def post(self, request, slug, project_id, entity_id):
|
||||
asset_ids = request.data.get("asset_ids", [])
|
||||
|
||||
# Check if the asset ids are provided
|
||||
if not asset_ids:
|
||||
return Response(
|
||||
{
|
||||
"error": "No asset ids provided.",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# get the asset id
|
||||
assets = FileAsset.objects.filter(
|
||||
id__in=asset_ids,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
|
||||
# Get the first asset
|
||||
asset = assets.first()
|
||||
|
||||
if not asset:
|
||||
return Response(
|
||||
{
|
||||
"error": "The requested asset could not be found.",
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
# Check if the asset is uploaded
|
||||
if asset.entity_type == FileAsset.EntityTypeContext.PROJECT_COVER:
|
||||
assets.update(
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
if asset.entity_type == FileAsset.EntityTypeContext.ISSUE_DESCRIPTION:
|
||||
assets.update(
|
||||
issue_id=entity_id,
|
||||
)
|
||||
|
||||
if (
|
||||
asset.entity_type
|
||||
== FileAsset.EntityTypeContext.COMMENT_DESCRIPTION
|
||||
):
|
||||
assets.update(
|
||||
comment_id=entity_id,
|
||||
)
|
||||
|
||||
if asset.entity_type == FileAsset.EntityTypeContext.PAGE_DESCRIPTION:
|
||||
assets.update(
|
||||
page_id=entity_id,
|
||||
)
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
# Django imports
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db import models
|
||||
from django.db.models import (
|
||||
Case,
|
||||
CharField,
|
||||
|
|
@ -18,7 +19,7 @@ from django.db.models import (
|
|||
Sum,
|
||||
FloatField,
|
||||
)
|
||||
from django.db.models.functions import Coalesce, Cast
|
||||
from django.db.models.functions import Coalesce, Cast, Concat
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
|
|
@ -139,7 +140,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
|
|||
Prefetch(
|
||||
"issue_cycle__issue__assignees",
|
||||
queryset=User.objects.only(
|
||||
"avatar", "first_name", "id"
|
||||
"avatar_asset", "first_name", "id"
|
||||
).distinct(),
|
||||
)
|
||||
)
|
||||
|
|
@ -400,8 +401,27 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
|
|||
)
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.values("display_name", "assignee_id", "avatar")
|
||||
.annotate(
|
||||
avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(
|
||||
assignees__avatar_asset__isnull=True,
|
||||
then="assignees__avatar",
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar_url")
|
||||
.annotate(
|
||||
total_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField())
|
||||
|
|
@ -494,13 +514,13 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
|
|||
.annotate(first_name=F("assignees__first_name"))
|
||||
.annotate(last_name=F("assignees__last_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.annotate(avatar_url=F("assignees__avatar_url"))
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.values(
|
||||
"first_name",
|
||||
"last_name",
|
||||
"assignee_id",
|
||||
"avatar",
|
||||
"avatar_url",
|
||||
"display_name",
|
||||
)
|
||||
.annotate(
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ from django.db.models import (
|
|||
Sum,
|
||||
FloatField,
|
||||
)
|
||||
from django.db.models.functions import Coalesce, Cast
|
||||
from django.db import models
|
||||
from django.db.models.functions import Coalesce, Cast, Concat
|
||||
from django.utils import timezone
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
|
|
@ -81,7 +82,7 @@ class CycleViewSet(BaseViewSet):
|
|||
Prefetch(
|
||||
"issue_cycle__issue__assignees",
|
||||
queryset=User.objects.only(
|
||||
"avatar", "first_name", "id"
|
||||
"avatar_asset", "first_name", "id"
|
||||
).distinct(),
|
||||
)
|
||||
)
|
||||
|
|
@ -667,8 +668,27 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
|||
)
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.values("display_name", "assignee_id", "avatar")
|
||||
.annotate(
|
||||
avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(
|
||||
assignees__avatar_asset__isnull=True,
|
||||
then="assignees__avatar",
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar_url")
|
||||
.annotate(
|
||||
total_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField())
|
||||
|
|
@ -705,7 +725,8 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
|||
if item["assignee_id"]
|
||||
else None
|
||||
),
|
||||
"avatar": item["avatar"],
|
||||
"avatar": item.get("avatar"),
|
||||
"avatar_url": item.get("avatar_url"),
|
||||
"total_estimates": item["total_estimates"],
|
||||
"completed_estimates": item["completed_estimates"],
|
||||
"pending_estimates": item["pending_estimates"],
|
||||
|
|
@ -782,8 +803,27 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
|||
)
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.values("display_name", "assignee_id", "avatar")
|
||||
.annotate(
|
||||
avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(
|
||||
assignees__avatar_asset__isnull=True,
|
||||
then="assignees__avatar",
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar_url")
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
|
|
@ -822,7 +862,8 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
|||
"assignee_id": (
|
||||
str(item["assignee_id"]) if item["assignee_id"] else None
|
||||
),
|
||||
"avatar": item["avatar"],
|
||||
"avatar": item.get("avatar"),
|
||||
"avatar_url": item.get("avatar_url"),
|
||||
"total_issues": item["total_issues"],
|
||||
"completed_issues": item["completed_issues"],
|
||||
"pending_issues": item["pending_issues"],
|
||||
|
|
@ -1201,8 +1242,27 @@ class CycleAnalyticsEndpoint(BaseAPIView):
|
|||
)
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.values("display_name", "assignee_id", "avatar")
|
||||
.annotate(
|
||||
avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(
|
||||
assignees__avatar_asset__isnull=True,
|
||||
then="assignees__avatar",
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar_url")
|
||||
.annotate(
|
||||
total_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField())
|
||||
|
|
@ -1285,8 +1345,27 @@ class CycleAnalyticsEndpoint(BaseAPIView):
|
|||
)
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.values("display_name", "assignee_id", "avatar")
|
||||
.annotate(
|
||||
avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(
|
||||
assignees__avatar_asset__isnull=True,
|
||||
then="assignees__avatar",
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar_url")
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"assignee_id",
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ from plane.db.models import (
|
|||
Cycle,
|
||||
CycleIssue,
|
||||
Issue,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
IssueLink,
|
||||
)
|
||||
from plane.utils.grouper import (
|
||||
|
|
@ -110,8 +110,9 @@ class CycleIssueViewSet(BaseViewSet):
|
|||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ from plane.db.models import (
|
|||
DashboardWidget,
|
||||
Issue,
|
||||
IssueActivity,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
IssueLink,
|
||||
IssueRelation,
|
||||
Project,
|
||||
|
|
@ -56,7 +56,8 @@ def dashboard_overview_stats(self, request, slug):
|
|||
project__project_projectmember__member=request.user,
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
).filter(
|
||||
)
|
||||
.filter(
|
||||
Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=True,
|
||||
|
|
@ -83,7 +84,8 @@ def dashboard_overview_stats(self, request, slug):
|
|||
project__project_projectmember__member=request.user,
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
).filter(
|
||||
)
|
||||
.filter(
|
||||
Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=True,
|
||||
|
|
@ -108,7 +110,8 @@ def dashboard_overview_stats(self, request, slug):
|
|||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
created_by_id=request.user.id,
|
||||
).filter(
|
||||
)
|
||||
.filter(
|
||||
Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=True,
|
||||
|
|
@ -134,7 +137,8 @@ def dashboard_overview_stats(self, request, slug):
|
|||
project__project_projectmember__member=request.user,
|
||||
assignees__in=[request.user],
|
||||
state__group="completed",
|
||||
).filter(
|
||||
)
|
||||
.filter(
|
||||
Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=True,
|
||||
|
|
@ -195,8 +199,9 @@ def dashboard_assigned_issues(self, request, slug):
|
|||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
|
@ -358,8 +363,9 @@ def dashboard_created_issues(self, request, slug):
|
|||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ from plane.db.models import (
|
|||
Issue,
|
||||
State,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
Project,
|
||||
ProjectMember,
|
||||
)
|
||||
|
|
@ -120,8 +120,9 @@ class InboxIssueViewSet(BaseViewSet):
|
|||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ from plane.app.serializers import (
|
|||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
IssueLink,
|
||||
IssueSubscriber,
|
||||
IssueReaction,
|
||||
|
|
@ -79,8 +79,9 @@ class IssueArchiveViewSet(BaseViewSet):
|
|||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
|
@ -236,12 +237,6 @@ class IssueArchiveViewSet(BaseViewSet):
|
|||
),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_attachment",
|
||||
queryset=IssueAttachment.objects.select_related("issue"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_link",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
# Python imports
|
||||
import json
|
||||
import uuid
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponseRedirect
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
|
|
@ -13,21 +16,28 @@ from rest_framework.parsers import MultiPartParser, FormParser
|
|||
# Module imports
|
||||
from .. import BaseAPIView
|
||||
from plane.app.serializers import IssueAttachmentSerializer
|
||||
from plane.db.models import IssueAttachment
|
||||
from plane.db.models import FileAsset, Workspace
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.settings.storage import S3Storage
|
||||
|
||||
|
||||
class IssueAttachmentEndpoint(BaseAPIView):
|
||||
serializer_class = IssueAttachmentSerializer
|
||||
model = IssueAttachment
|
||||
model = FileAsset
|
||||
parser_classes = (MultiPartParser, FormParser)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def post(self, request, slug, project_id, issue_id):
|
||||
serializer = IssueAttachmentSerializer(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(
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
workspace_id=workspace.id,
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
issue_activity.delay(
|
||||
type="attachment.activity.created",
|
||||
requested_data=None,
|
||||
|
|
@ -45,9 +55,9 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
|||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission([ROLE.ADMIN], creator=True, model=IssueAttachment)
|
||||
@allow_permission([ROLE.ADMIN], creator=True, model=FileAsset)
|
||||
def delete(self, request, slug, project_id, issue_id, pk):
|
||||
issue_attachment = IssueAttachment.objects.get(pk=pk)
|
||||
issue_attachment = FileAsset.objects.get(pk=pk)
|
||||
issue_attachment.asset.delete(save=False)
|
||||
issue_attachment.delete()
|
||||
issue_activity.delay(
|
||||
|
|
@ -72,8 +82,182 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
|||
]
|
||||
)
|
||||
def get(self, request, slug, project_id, issue_id):
|
||||
issue_attachments = IssueAttachment.objects.filter(
|
||||
issue_attachments = FileAsset.objects.filter(
|
||||
issue_id=issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
serializer = IssueAttachmentSerializer(issue_attachments, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class IssueAttachmentV2Endpoint(BaseAPIView):
|
||||
|
||||
serializer_class = IssueAttachmentSerializer
|
||||
model = FileAsset
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def post(self, request, slug, project_id, issue_id):
|
||||
name = request.data.get("name")
|
||||
type = request.data.get("type", False)
|
||||
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
|
||||
|
||||
if not type or type not in settings.ATTACHMENT_MIME_TYPES:
|
||||
return Response(
|
||||
{
|
||||
"error": "Invalid file type.",
|
||||
"status": False,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get the workspace
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
# asset key
|
||||
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
|
||||
|
||||
# Get the size limit
|
||||
size_limit = min(size, settings.FILE_SIZE_LIMIT)
|
||||
|
||||
# Create a File Asset
|
||||
asset = FileAsset.objects.create(
|
||||
attributes={
|
||||
"name": name,
|
||||
"type": type,
|
||||
"size": size_limit,
|
||||
},
|
||||
asset=asset_key,
|
||||
size=size_limit,
|
||||
workspace_id=workspace.id,
|
||||
created_by=request.user,
|
||||
issue_id=issue_id,
|
||||
project_id=project_id,
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
|
||||
# Get the presigned URL
|
||||
storage = S3Storage(request=request)
|
||||
# Generate a presigned URL to share an S3 object
|
||||
presigned_url = storage.generate_presigned_post(
|
||||
object_name=asset_key,
|
||||
file_type=type,
|
||||
file_size=size_limit,
|
||||
)
|
||||
# Return the presigned URL
|
||||
return Response(
|
||||
{
|
||||
"upload_data": presigned_url,
|
||||
"asset_id": str(asset.id),
|
||||
"attachment": IssueAttachmentSerializer(asset).data,
|
||||
"asset_url": asset.asset_url,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN], creator=True, model=FileAsset)
|
||||
def delete(self, request, slug, project_id, issue_id, pk):
|
||||
issue_attachment = FileAsset.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
issue_attachment.is_deleted = True
|
||||
issue_attachment.deleted_at = timezone.now()
|
||||
issue_attachment.save()
|
||||
|
||||
issue_activity.delay(
|
||||
type="attachment.activity.deleted",
|
||||
requested_data=None,
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=str(issue_id),
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@allow_permission(
|
||||
[
|
||||
ROLE.ADMIN,
|
||||
ROLE.MEMBER,
|
||||
ROLE.GUEST,
|
||||
]
|
||||
)
|
||||
def get(self, request, slug, project_id, issue_id, pk=None):
|
||||
if pk:
|
||||
# Get the asset
|
||||
asset = FileAsset.objects.get(
|
||||
id=pk, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
|
||||
# Check if the asset is uploaded
|
||||
if not asset.is_uploaded:
|
||||
return Response(
|
||||
{
|
||||
"error": "The asset is not uploaded.",
|
||||
"status": False,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
storage = S3Storage(request=request)
|
||||
presigned_url = storage.generate_presigned_url(
|
||||
object_name=asset.asset.name,
|
||||
disposition="attachment",
|
||||
filename=asset.attributes.get("name"),
|
||||
)
|
||||
return HttpResponseRedirect(presigned_url)
|
||||
|
||||
# Get all the attachments
|
||||
issue_attachments = FileAsset.objects.filter(
|
||||
issue_id=issue_id,
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
is_uploaded=True,
|
||||
)
|
||||
# Serialize the attachments
|
||||
serializer = IssueAttachmentSerializer(issue_attachments, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission(
|
||||
[
|
||||
ROLE.ADMIN,
|
||||
ROLE.MEMBER,
|
||||
ROLE.GUEST,
|
||||
]
|
||||
)
|
||||
def patch(self, request, slug, project_id, issue_id, pk):
|
||||
issue_attachment = FileAsset.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
serializer = IssueAttachmentSerializer(issue_attachment)
|
||||
|
||||
# Send this activity only if the attachment is not uploaded before
|
||||
if not issue_attachment.is_uploaded:
|
||||
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"),
|
||||
)
|
||||
|
||||
# Update the attachment
|
||||
issue_attachment.is_uploaded = True
|
||||
|
||||
# Get the storage metadata
|
||||
if issue_attachment.storage_metadata is None:
|
||||
storage = S3Storage(request=request)
|
||||
issue_attachment.storage_metadata = storage.get_object_metadata(
|
||||
issue_attachment.asset.name
|
||||
)
|
||||
issue_attachment.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ from plane.app.serializers import (
|
|||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
IssueLink,
|
||||
IssueUserProperty,
|
||||
IssueReaction,
|
||||
|
|
@ -91,8 +91,9 @@ class IssueListEndpoint(BaseAPIView):
|
|||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
|
@ -214,8 +215,9 @@ class IssueViewSet(BaseViewSet):
|
|||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
|
@ -500,12 +502,6 @@ class IssueViewSet(BaseViewSet):
|
|||
),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_attachment",
|
||||
queryset=IssueAttachment.objects.select_related("issue"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_link",
|
||||
|
|
@ -759,14 +755,6 @@ class IssuePaginatedViewSet(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")
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ from plane.db.models import (
|
|||
Project,
|
||||
IssueRelation,
|
||||
Issue,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
IssueLink,
|
||||
)
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
|
|
@ -91,8 +91,9 @@ class IssueRelationViewSet(BaseViewSet):
|
|||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ from plane.app.permissions import ProjectEntityPermission
|
|||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
)
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||
|
|
@ -56,8 +56,9 @@ class SubIssuesEndpoint(BaseAPIView):
|
|||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
|
|
|||
|
|
@ -14,9 +14,12 @@ from django.db.models import (
|
|||
Value,
|
||||
Sum,
|
||||
FloatField,
|
||||
Case,
|
||||
When,
|
||||
)
|
||||
from django.db.models.functions import Coalesce, Cast
|
||||
from django.db.models.functions import Coalesce, Cast, Concat
|
||||
from django.utils import timezone
|
||||
from django.db import models
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
|
|
@ -364,12 +367,31 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
|||
.annotate(last_name=F("assignees__last_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.annotate(
|
||||
avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(
|
||||
assignees__avatar_asset__isnull=True,
|
||||
then="assignees__avatar",
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.values(
|
||||
"first_name",
|
||||
"last_name",
|
||||
"assignee_id",
|
||||
"avatar",
|
||||
"avatar_url",
|
||||
"display_name",
|
||||
)
|
||||
.annotate(
|
||||
|
|
@ -437,7 +459,9 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
|||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
data["estimate_distribution"]["assignees"] = assignee_distribution
|
||||
data["estimate_distribution"][
|
||||
"assignees"
|
||||
] = assignee_distribution
|
||||
data["estimate_distribution"]["labels"] = label_distribution
|
||||
|
||||
if modules and modules.start_date and modules.target_date:
|
||||
|
|
@ -461,12 +485,31 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
|||
.annotate(last_name=F("assignees__last_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.annotate(
|
||||
avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(
|
||||
assignees__avatar_asset__isnull=True,
|
||||
then="assignees__avatar",
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.values(
|
||||
"first_name",
|
||||
"last_name",
|
||||
"assignee_id",
|
||||
"avatar",
|
||||
"avatar_url",
|
||||
"display_name",
|
||||
)
|
||||
.annotate(
|
||||
|
|
|
|||
|
|
@ -18,8 +18,11 @@ from django.db.models import (
|
|||
Value,
|
||||
Sum,
|
||||
FloatField,
|
||||
Case,
|
||||
When,
|
||||
)
|
||||
from django.db.models.functions import Coalesce, Cast
|
||||
from django.db import models
|
||||
from django.db.models.functions import Coalesce, Cast, Concat
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.utils import timezone
|
||||
|
||||
|
|
@ -481,12 +484,31 @@ class ModuleViewSet(BaseViewSet):
|
|||
.annotate(last_name=F("assignees__last_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.annotate(
|
||||
avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(
|
||||
assignees__avatar_asset__isnull=True,
|
||||
then="assignees__avatar",
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.values(
|
||||
"first_name",
|
||||
"last_name",
|
||||
"assignee_id",
|
||||
"avatar",
|
||||
"avatar_url",
|
||||
"display_name",
|
||||
)
|
||||
.annotate(
|
||||
|
|
@ -578,12 +600,31 @@ class ModuleViewSet(BaseViewSet):
|
|||
.annotate(last_name=F("assignees__last_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.annotate(
|
||||
avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(
|
||||
assignees__avatar_asset__isnull=True,
|
||||
then="assignees__avatar",
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.values(
|
||||
"first_name",
|
||||
"last_name",
|
||||
"assignee_id",
|
||||
"avatar",
|
||||
"avatar_url",
|
||||
"display_name",
|
||||
)
|
||||
.annotate(
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ from plane.app.serializers import (
|
|||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
IssueLink,
|
||||
ModuleIssue,
|
||||
Project,
|
||||
|
|
@ -73,8 +73,9 @@ class ModuleIssueViewSet(BaseViewSet):
|
|||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ from django.db.models.functions import Coalesce
|
|||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
|
||||
# Module imports
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.app.serializers import (
|
||||
PageLogSerializer,
|
||||
|
|
@ -35,10 +35,7 @@ from plane.db.models import (
|
|||
Project,
|
||||
)
|
||||
from plane.utils.error_codes import ERROR_CODES
|
||||
|
||||
# Module imports
|
||||
from ..base import BaseAPIView, BaseViewSet
|
||||
|
||||
from plane.bgtasks.page_transaction_task import page_transaction
|
||||
from plane.bgtasks.page_version_task import page_version
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ from plane.db.models import (
|
|||
from plane.utils.cache import cache_response
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
class ProjectViewSet(BaseViewSet):
|
||||
|
|
@ -720,6 +721,7 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView):
|
|||
"Prefix": "static/project-cover/",
|
||||
}
|
||||
|
||||
try:
|
||||
response = s3.list_objects_v2(**params)
|
||||
# Extracting file keys from the response
|
||||
if "Contents" in response:
|
||||
|
|
@ -732,6 +734,9 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView):
|
|||
)
|
||||
|
||||
return Response(files, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return Response([], status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class DeployBoardViewSet(BaseViewSet):
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ from plane.app.serializers import (
|
|||
)
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
IssueLink,
|
||||
IssueView,
|
||||
Workspace,
|
||||
|
|
@ -213,8 +213,9 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
|
|||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ from plane.db.models import (
|
|||
CycleIssue,
|
||||
Issue,
|
||||
IssueActivity,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
IssueLink,
|
||||
IssueSubscriber,
|
||||
Project,
|
||||
|
|
@ -128,8 +128,9 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
|||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
entity_identifier=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
|
@ -359,8 +360,8 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
|
|||
"email": user_data.email,
|
||||
"first_name": user_data.first_name,
|
||||
"last_name": user_data.last_name,
|
||||
"avatar": user_data.avatar,
|
||||
"cover_image": user_data.cover_image,
|
||||
"avatar_url": user_data.avatar_url,
|
||||
"cover_image_url": user_data.cover_image_url,
|
||||
"date_joined": user_data.date_joined,
|
||||
"user_timezone": user_data.user_timezone,
|
||||
"display_name": user_data.display_name,
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ def get_assignee_details(slug, filters):
|
|||
.distinct("assignees__id")
|
||||
.order_by("assignees__id")
|
||||
.values(
|
||||
"assignees__avatar",
|
||||
"assignees__avatar_url",
|
||||
"assignees__display_name",
|
||||
"assignees__first_name",
|
||||
"assignees__last_name",
|
||||
|
|
|
|||
|
|
@ -224,7 +224,7 @@ def send_email_notification(
|
|||
{
|
||||
"actor_comments": comment,
|
||||
"actor_detail": {
|
||||
"avatar_url": actor.avatar,
|
||||
"avatar_url": f"{base_api}{actor.avatar_url}",
|
||||
"first_name": actor.first_name,
|
||||
"last_name": actor.last_name,
|
||||
},
|
||||
|
|
@ -241,7 +241,7 @@ def send_email_notification(
|
|||
{
|
||||
"actor_comments": mention,
|
||||
"actor_detail": {
|
||||
"avatar_url": actor.avatar,
|
||||
"avatar_url": f"{base_api}{actor.avatar_url}",
|
||||
"first_name": actor.first_name,
|
||||
"last_name": actor.last_name,
|
||||
},
|
||||
|
|
@ -257,7 +257,7 @@ def send_email_notification(
|
|||
template_data.append(
|
||||
{
|
||||
"actor_detail": {
|
||||
"avatar_url": actor.avatar,
|
||||
"avatar_url": f"{base_api}{actor.avatar_url}",
|
||||
"first_name": actor.first_name,
|
||||
"last_name": actor.last_name,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# Python imports
|
||||
import os
|
||||
from datetime import timedelta
|
||||
|
||||
# Django imports
|
||||
|
|
@ -13,16 +14,14 @@ from plane.db.models import FileAsset
|
|||
|
||||
|
||||
@shared_task
|
||||
def delete_file_asset():
|
||||
# file assets to delete
|
||||
file_assets_to_delete = FileAsset.objects.filter(
|
||||
Q(is_deleted=True)
|
||||
& Q(updated_at__lte=timezone.now() - timedelta(days=7))
|
||||
def delete_unuploaded_file_asset():
|
||||
"""This task deletes unuploaded file assets older than a certain number of days."""
|
||||
FileAsset.objects.filter(
|
||||
Q(
|
||||
created_at__lt=timezone.now()
|
||||
- timedelta(
|
||||
days=int(os.environ.get("UNUPLOADED_ASSET_DELETE_DAYS", "7"))
|
||||
)
|
||||
|
||||
# Delete the file from storage and the file object from the database
|
||||
for file_asset in file_assets_to_delete:
|
||||
# Delete the file from storage
|
||||
file_asset.asset.delete(save=False)
|
||||
# Delete the file object
|
||||
file_asset.delete()
|
||||
)
|
||||
& Q(is_uploaded=False)
|
||||
).delete()
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ app.conf.beat_schedule = {
|
|||
"schedule": crontab(hour=0, minute=0),
|
||||
},
|
||||
"check-every-day-to-delete-file-asset": {
|
||||
"task": "plane.bgtasks.file_asset_task.delete_file_asset",
|
||||
"task": "plane.bgtasks.file_asset_task.delete_unuploaded_file_asset",
|
||||
"schedule": crontab(hour=0, minute=0),
|
||||
},
|
||||
"check-every-five-minutes-to-send-email-notifications": {
|
||||
|
|
|
|||
|
|
@ -1,67 +1,45 @@
|
|||
# Python imports
|
||||
import os
|
||||
import boto3
|
||||
import json
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
# 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}/*"],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
try:
|
||||
s3_client.put_bucket_policy(
|
||||
Bucket=bucket_name, Policy=json.dumps(public_policy)
|
||||
)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Public read access policy set for bucket '{bucket_name}'."
|
||||
)
|
||||
)
|
||||
except ClientError as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f"Error setting public read access policy: {e}"
|
||||
)
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Create a session using the credentials from Django settings
|
||||
try:
|
||||
session = boto3.session.Session(
|
||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
s3_client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=os.environ.get(
|
||||
"AWS_S3_ENDPOINT_URL"
|
||||
), # MinIO endpoint
|
||||
aws_access_key_id=os.environ.get(
|
||||
"AWS_ACCESS_KEY_ID"
|
||||
), # MinIO access key
|
||||
aws_secret_access_key=os.environ.get(
|
||||
"AWS_SECRET_ACCESS_KEY"
|
||||
), # MinIO secret key
|
||||
region_name=os.environ.get("AWS_REGION"), # MinIO region
|
||||
config=boto3.session.Config(signature_version="s3v4"),
|
||||
)
|
||||
# Create an S3 client using the session
|
||||
s3_client = session.client(
|
||||
"s3", endpoint_url=settings.AWS_S3_ENDPOINT_URL
|
||||
)
|
||||
bucket_name = settings.AWS_STORAGE_BUCKET_NAME
|
||||
|
||||
# Get the bucket name from the environment
|
||||
bucket_name = os.environ.get("AWS_S3_BUCKET_NAME")
|
||||
self.stdout.write(self.style.NOTICE("Checking bucket..."))
|
||||
|
||||
# Check if the bucket exists
|
||||
s3_client.head_bucket(Bucket=bucket_name)
|
||||
|
||||
self.set_bucket_public_policy(s3_client, bucket_name)
|
||||
# If the bucket exists, print a success message
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Bucket '{bucket_name}' exists.")
|
||||
)
|
||||
return
|
||||
except ClientError as e:
|
||||
error_code = int(e.response["Error"]["Code"])
|
||||
bucket_name = settings.AWS_STORAGE_BUCKET_NAME
|
||||
bucket_name = os.environ.get("AWS_S3_BUCKET_NAME")
|
||||
if error_code == 404:
|
||||
# Bucket does not exist, create it
|
||||
self.stdout.write(
|
||||
|
|
@ -76,13 +54,16 @@ class Command(BaseCommand):
|
|||
f"Bucket '{bucket_name}' created successfully."
|
||||
)
|
||||
)
|
||||
self.set_bucket_public_policy(s3_client, bucket_name)
|
||||
|
||||
# Handle the exception if the bucket creation fails
|
||||
except ClientError as create_error:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f"Failed to create bucket: {create_error}"
|
||||
)
|
||||
)
|
||||
|
||||
# Handle the exception if access to the bucket is forbidden
|
||||
elif error_code == 403:
|
||||
# Access to the bucket is forbidden
|
||||
self.stdout.write(
|
||||
|
|
|
|||
209
apiserver/plane/db/management/commands/update_bucket.py
Normal file
209
apiserver/plane/db/management/commands/update_bucket.py
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
# Python imports
|
||||
import os
|
||||
import boto3
|
||||
from botocore.exceptions import ClientError
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.core.management import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Create the default bucket for the instance"
|
||||
|
||||
def get_s3_client(self):
|
||||
s3_client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=os.environ.get(
|
||||
"AWS_S3_ENDPOINT_URL"
|
||||
), # MinIO endpoint
|
||||
aws_access_key_id=os.environ.get(
|
||||
"AWS_ACCESS_KEY_ID"
|
||||
), # MinIO access key
|
||||
aws_secret_access_key=os.environ.get(
|
||||
"AWS_SECRET_ACCESS_KEY"
|
||||
), # MinIO secret key
|
||||
region_name=os.environ.get("AWS_REGION"), # MinIO region
|
||||
config=boto3.session.Config(signature_version="s3v4"),
|
||||
)
|
||||
return s3_client
|
||||
|
||||
# Check if the access key has the required permissions
|
||||
def check_s3_permissions(self, bucket_name):
|
||||
s3_client = self.get_s3_client()
|
||||
permissions = {
|
||||
"s3:GetObject": False,
|
||||
"s3:ListBucket": False,
|
||||
"s3:PutBucketPolicy": False,
|
||||
"s3:PutObject": False,
|
||||
}
|
||||
|
||||
# 1. Test s3:ListBucket (attempt to list the bucket contents)
|
||||
try:
|
||||
s3_client.list_objects_v2(Bucket=bucket_name)
|
||||
permissions["s3:ListBucket"] = True
|
||||
except ClientError as e:
|
||||
if e.response["Error"]["Code"] == "AccessDenied":
|
||||
self.stdout.write("ListBucket permission denied.")
|
||||
else:
|
||||
self.stdout.write(f"Error in ListBucket: {e}")
|
||||
|
||||
# 2. Test s3:GetObject (attempt to get a specific object)
|
||||
try:
|
||||
response = s3_client.list_objects_v2(Bucket=bucket_name)
|
||||
if "Contents" in response:
|
||||
test_object_key = response["Contents"][0]["Key"]
|
||||
s3_client.get_object(Bucket=bucket_name, Key=test_object_key)
|
||||
permissions["s3:GetObject"] = True
|
||||
except ClientError as e:
|
||||
if e.response["Error"]["Code"] == "AccessDenied":
|
||||
self.stdout.write("GetObject permission denied.")
|
||||
else:
|
||||
self.stdout.write(f"Error in GetObject: {e}")
|
||||
|
||||
# 3. Test s3:PutObject (attempt to upload an object)
|
||||
try:
|
||||
s3_client.put_object(
|
||||
Bucket=bucket_name,
|
||||
Key="test_permission_check.txt",
|
||||
Body=b"Test",
|
||||
)
|
||||
self.stdout.write("PutObject permission granted.")
|
||||
permissions["s3:PutObject"] = True
|
||||
# Clean up
|
||||
except ClientError as e:
|
||||
if e.response["Error"]["Code"] == "AccessDenied":
|
||||
self.stdout.write("PutObject permission denied.")
|
||||
else:
|
||||
self.stdout.write(f"Error in PutObject: {e}")
|
||||
|
||||
# Clean up
|
||||
try:
|
||||
s3_client.delete_object(
|
||||
Bucket=bucket_name, Key="test_permission_check.txt"
|
||||
)
|
||||
except ClientError:
|
||||
self.stdout.write("Coudn't delete test object")
|
||||
|
||||
# 4. Test s3:PutBucketPolicy (attempt to put a bucket policy)
|
||||
try:
|
||||
policy = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": f"arn:aws:s3:::{bucket_name}/*",
|
||||
}
|
||||
],
|
||||
}
|
||||
s3_client.put_bucket_policy(
|
||||
Bucket=bucket_name, Policy=json.dumps(policy)
|
||||
)
|
||||
permissions["s3:PutBucketPolicy"] = True
|
||||
except ClientError as e:
|
||||
if e.response["Error"]["Code"] == "AccessDenied":
|
||||
self.stdout.write("PutBucketPolicy permission denied.")
|
||||
else:
|
||||
self.stdout.write(f"Error in PutBucketPolicy: {e}")
|
||||
|
||||
return permissions
|
||||
|
||||
def generate_bucket_policy(self, bucket_name):
|
||||
s3_client = self.get_s3_client()
|
||||
response = s3_client.list_objects_v2(Bucket=bucket_name)
|
||||
public_object_resource = []
|
||||
if "Contents" in response:
|
||||
for obj in response["Contents"]:
|
||||
object_key = obj["Key"]
|
||||
public_object_resource.append(
|
||||
f"arn:aws:s3:::{bucket_name}/{object_key}"
|
||||
)
|
||||
bucket_policy = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": public_object_resource,
|
||||
}
|
||||
],
|
||||
}
|
||||
return bucket_policy
|
||||
|
||||
def make_objects_public(self, bucket_name):
|
||||
# Initialize S3 client
|
||||
s3_client = self.get_s3_client()
|
||||
# Get the bucket policy
|
||||
bucket_policy = self.generate_bucket_policy(bucket_name)
|
||||
# Apply the policy to the bucket
|
||||
s3_client.put_bucket_policy(
|
||||
Bucket=bucket_name, Policy=json.dumps(bucket_policy)
|
||||
)
|
||||
# Print a success message
|
||||
self.stdout.write(
|
||||
"Bucket is private, but existing objects remain public."
|
||||
)
|
||||
return
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Create a session using the credentials from Django settings
|
||||
try:
|
||||
# Check if the bucket exists
|
||||
s3_client = self.get_s3_client()
|
||||
# Get the bucket name from the environment
|
||||
bucket_name = os.environ.get("AWS_S3_BUCKET_NAME")
|
||||
self.stdout.write(self.style.NOTICE("Checking bucket..."))
|
||||
# Check if the bucket exists
|
||||
s3_client.head_bucket(Bucket=bucket_name)
|
||||
|
||||
# If the bucket exists, print a success message
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Bucket '{bucket_name}' exists.")
|
||||
)
|
||||
|
||||
# Check the permissions of the access key
|
||||
permissions = self.check_s3_permissions(bucket_name)
|
||||
|
||||
if all(permissions.values()):
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
"Access key has the required permissions."
|
||||
)
|
||||
)
|
||||
# Making the existing objects public
|
||||
self.make_objects_public(bucket_name)
|
||||
|
||||
# If the access key does not have PutBucketPolicy permission
|
||||
# write the bucket policy to a file
|
||||
if (
|
||||
all(
|
||||
{
|
||||
k: v
|
||||
for k, v in permissions.items()
|
||||
if k != "s3:PutBucketPolicy"
|
||||
}.values()
|
||||
)
|
||||
and not permissions["s3:PutBucketPolicy"]
|
||||
):
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
"Access key does not have PutBucketPolicy permission."
|
||||
)
|
||||
)
|
||||
# Writing to a file
|
||||
with open("permissions.json", "w") as f:
|
||||
f.write(
|
||||
json.dumps(self.generate_bucket_policy(bucket_name))
|
||||
)
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
"Permissions have been written to permissions.json."
|
||||
)
|
||||
)
|
||||
return
|
||||
except Exception as ex:
|
||||
# Handle any other exception
|
||||
self.stdout.write(self.style.ERROR(f"An error occurred: {ex}"))
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
# Generated by Django 4.2.15 on 2024-10-09 06:19
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import plane.db.models.asset
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"db",
|
||||
"0077_draftissue_cycle_user_timezone_project_user_timezone_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="fileasset",
|
||||
name="comment",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="assets",
|
||||
to="db.issuecomment",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="fileasset",
|
||||
name="entity_type",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("ISSUE_ATTACHMENT", "Issue Attachment"),
|
||||
("ISSUE_DESCRIPTION", "Issue Description"),
|
||||
("COMMENT_DESCRIPTION", "Comment Description"),
|
||||
("PAGE_DESCRIPTION", "Page Description"),
|
||||
("USER_COVER", "User Cover"),
|
||||
("USER_AVATAR", "User Avatar"),
|
||||
("WORKSPACE_LOGO", "Workspace Logo"),
|
||||
("PROJECT_COVER", "Project Cover"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="fileasset",
|
||||
name="external_id",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="fileasset",
|
||||
name="external_source",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="fileasset",
|
||||
name="is_uploaded",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="fileasset",
|
||||
name="issue",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="assets",
|
||||
to="db.issue",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="fileasset",
|
||||
name="page",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="assets",
|
||||
to="db.page",
|
||||
),
|
||||
),
|
||||
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.AddField(
|
||||
model_name="fileasset",
|
||||
name="size",
|
||||
field=models.FloatField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="fileasset",
|
||||
name="storage_metadata",
|
||||
field=models.JSONField(blank=True, default=dict, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="fileasset",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="assets",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="project",
|
||||
name="cover_image_asset",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="project_cover_image",
|
||||
to="db.fileasset",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="avatar_asset",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="user_avatar",
|
||||
to="db.fileasset",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="cover_image_asset",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="user_cover_image",
|
||||
to="db.fileasset",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="workspace",
|
||||
name="logo_asset",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="workspace_logo",
|
||||
to="db.fileasset",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="fileasset",
|
||||
name="asset",
|
||||
field=models.FileField(
|
||||
max_length=800, upload_to=plane.db.models.asset.get_upload_path
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="integration",
|
||||
name="avatar_url",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="project",
|
||||
name="cover_image",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="workspace",
|
||||
name="logo",
|
||||
field=models.TextField(blank=True, null=True, verbose_name="Logo"),
|
||||
),
|
||||
]
|
||||
64
apiserver/plane/db/migrations/0079_auto_20241009_0619.py
Normal file
64
apiserver/plane/db/migrations/0079_auto_20241009_0619.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# Generated by Django 4.2.15 on 2024-10-09 06:19
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def move_attachment_to_fileasset(apps, schema_editor):
|
||||
FileAsset = apps.get_model("db", "FileAsset")
|
||||
IssueAttachment = apps.get_model("db", "IssueAttachment")
|
||||
|
||||
bulk_issue_attachment = []
|
||||
for issue_attachment in IssueAttachment.objects.values(
|
||||
"issue_id",
|
||||
"project_id",
|
||||
"workspace_id",
|
||||
"asset",
|
||||
"attributes",
|
||||
"external_source",
|
||||
"external_id",
|
||||
"deleted_at",
|
||||
"created_by_id",
|
||||
"updated_by_id",
|
||||
):
|
||||
bulk_issue_attachment.append(
|
||||
FileAsset(
|
||||
issue_id=issue_attachment["issue_id"],
|
||||
entity_type="ISSUE_ATTACHMENT",
|
||||
project_id=issue_attachment["project_id"],
|
||||
workspace_id=issue_attachment["workspace_id"],
|
||||
attributes=issue_attachment["attributes"],
|
||||
asset=issue_attachment["asset"],
|
||||
external_source=issue_attachment["external_source"],
|
||||
external_id=issue_attachment["external_id"],
|
||||
deleted_at=issue_attachment["deleted_at"],
|
||||
created_by_id=issue_attachment["created_by_id"],
|
||||
updated_by_id=issue_attachment["updated_by_id"],
|
||||
size=issue_attachment["attributes"].get("size", 0),
|
||||
)
|
||||
)
|
||||
|
||||
FileAsset.objects.bulk_create(bulk_issue_attachment, batch_size=1000)
|
||||
|
||||
|
||||
def mark_existing_file_uploads(apps, schema_editor):
|
||||
FileAsset = apps.get_model("db", "FileAsset")
|
||||
# Mark all existing file uploads as uploaded
|
||||
FileAsset.objects.update(is_uploaded=True)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("db", "0078_fileasset_comment_fileasset_entity_type_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
move_attachment_to_fileasset,
|
||||
reverse_code=migrations.RunPython.noop,
|
||||
),
|
||||
migrations.RunPython(
|
||||
mark_existing_file_uploads,
|
||||
reverse_code=migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
|
|
@ -24,7 +24,6 @@ from .issue import (
|
|||
Issue,
|
||||
IssueActivity,
|
||||
IssueAssignee,
|
||||
IssueAttachment,
|
||||
IssueBlocker,
|
||||
IssueComment,
|
||||
IssueLabel,
|
||||
|
|
|
|||
|
|
@ -5,14 +5,13 @@ from uuid import uuid4
|
|||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.core.validators import FileExtensionValidator
|
||||
|
||||
# Module import
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
def get_upload_path(instance, filename):
|
||||
filename = filename[:50]
|
||||
|
||||
if instance.workspace_id is not None:
|
||||
return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
|
||||
return f"user-{uuid4().hex}-{filename}"
|
||||
|
|
@ -28,13 +27,26 @@ class FileAsset(BaseModel):
|
|||
A file asset.
|
||||
"""
|
||||
|
||||
class EntityTypeContext(models.TextChoices):
|
||||
ISSUE_ATTACHMENT = "ISSUE_ATTACHMENT"
|
||||
ISSUE_DESCRIPTION = "ISSUE_DESCRIPTION"
|
||||
COMMENT_DESCRIPTION = "COMMENT_DESCRIPTION"
|
||||
PAGE_DESCRIPTION = "PAGE_DESCRIPTION"
|
||||
USER_COVER = "USER_COVER"
|
||||
USER_AVATAR = "USER_AVATAR"
|
||||
WORKSPACE_LOGO = "WORKSPACE_LOGO"
|
||||
PROJECT_COVER = "PROJECT_COVER"
|
||||
|
||||
attributes = models.JSONField(default=dict)
|
||||
asset = models.FileField(
|
||||
upload_to=get_upload_path,
|
||||
validators=[
|
||||
FileExtensionValidator(allowed_extensions=["jpg", "jpeg", "png"]),
|
||||
file_size,
|
||||
],
|
||||
max_length=800,
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
"db.User",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
related_name="assets",
|
||||
)
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace",
|
||||
|
|
@ -42,8 +54,43 @@ class FileAsset(BaseModel):
|
|||
null=True,
|
||||
related_name="assets",
|
||||
)
|
||||
project = models.ForeignKey(
|
||||
"db.Project",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
related_name="assets",
|
||||
)
|
||||
issue = models.ForeignKey(
|
||||
"db.Issue",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
related_name="assets",
|
||||
)
|
||||
comment = models.ForeignKey(
|
||||
"db.IssueComment",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
related_name="assets",
|
||||
)
|
||||
page = models.ForeignKey(
|
||||
"db.Page",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
related_name="assets",
|
||||
)
|
||||
entity_type = models.CharField(
|
||||
max_length=255,
|
||||
choices=EntityTypeContext.choices,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
is_archived = models.BooleanField(default=False)
|
||||
external_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
size = models.FloatField(default=0)
|
||||
is_uploaded = models.BooleanField(default=False)
|
||||
storage_metadata = models.JSONField(default=dict, null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "File Asset"
|
||||
|
|
@ -53,3 +100,25 @@ class FileAsset(BaseModel):
|
|||
|
||||
def __str__(self):
|
||||
return str(self.asset)
|
||||
|
||||
@property
|
||||
def asset_url(self):
|
||||
if (
|
||||
self.entity_type == self.EntityTypeContext.WORKSPACE_LOGO
|
||||
or self.entity_type == self.EntityTypeContext.USER_AVATAR
|
||||
or self.entity_type == self.EntityTypeContext.USER_COVER
|
||||
or self.entity_type == self.EntityTypeContext.PROJECT_COVER
|
||||
):
|
||||
return f"/api/assets/v2/static/{self.id}/"
|
||||
|
||||
if self.entity_type == self.EntityTypeContext.ISSUE_ATTACHMENT:
|
||||
return f"/api/assets/v2/workspaces/{self.workspace.slug}/projects/{self.project_id}/issues/{self.issue_id}/attachments/{self.id}/"
|
||||
|
||||
if self.entity_type in [
|
||||
self.EntityTypeContext.ISSUE_DESCRIPTION,
|
||||
self.EntityTypeContext.COMMENT_DESCRIPTION,
|
||||
self.EntityTypeContext.PAGE_DESCRIPTION,
|
||||
]:
|
||||
return f"/api/assets/v2/workspaces/{self.workspace.slug}/projects/{self.project_id}/{self.id}/"
|
||||
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -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.TextField(blank=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
"""Return provider of the integration"""
|
||||
|
|
|
|||
|
|
@ -99,7 +99,14 @@ class Project(BaseModel):
|
|||
is_time_tracking_enabled = models.BooleanField(default=False)
|
||||
is_issue_type_enabled = models.BooleanField(default=False)
|
||||
guest_view_all_features = models.BooleanField(default=False)
|
||||
cover_image = models.URLField(blank=True, null=True, max_length=800)
|
||||
cover_image = models.TextField(blank=True, null=True)
|
||||
cover_image_asset = models.ForeignKey(
|
||||
"db.FileAsset",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="project_cover_image",
|
||||
)
|
||||
estimate = models.ForeignKey(
|
||||
"db.Estimate",
|
||||
on_delete=models.SET_NULL,
|
||||
|
|
@ -126,6 +133,18 @@ class Project(BaseModel):
|
|||
max_length=255, default="UTC", choices=TIMEZONE_CHOICES
|
||||
)
|
||||
|
||||
@property
|
||||
def cover_image_url(self):
|
||||
# Return cover image url
|
||||
if self.cover_image_asset:
|
||||
return self.cover_image_asset.asset_url
|
||||
|
||||
# Return cover image url
|
||||
if self.cover_image:
|
||||
return self.cover_image
|
||||
|
||||
return None
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the project"""
|
||||
return f"{self.name} <{self.workspace.name}>"
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from django.dispatch import receiver
|
|||
from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import FileAsset
|
||||
from ..mixins import TimeAuditModel
|
||||
|
||||
|
||||
|
|
@ -48,8 +49,24 @@ class User(AbstractBaseUser, PermissionsMixin):
|
|||
display_name = models.CharField(max_length=255, default="")
|
||||
first_name = models.CharField(max_length=255, blank=True)
|
||||
last_name = models.CharField(max_length=255, blank=True)
|
||||
# avatar
|
||||
avatar = models.TextField(blank=True)
|
||||
avatar_asset = models.ForeignKey(
|
||||
FileAsset,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="user_avatar",
|
||||
)
|
||||
# cover image
|
||||
cover_image = models.URLField(blank=True, null=True, max_length=800)
|
||||
cover_image_asset = models.ForeignKey(
|
||||
FileAsset,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="user_cover_image",
|
||||
)
|
||||
|
||||
# tracking metrics
|
||||
date_joined = models.DateTimeField(
|
||||
|
|
@ -111,6 +128,28 @@ class User(AbstractBaseUser, PermissionsMixin):
|
|||
def __str__(self):
|
||||
return f"{self.username} <{self.email}>"
|
||||
|
||||
@property
|
||||
def avatar_url(self):
|
||||
# Return the logo asset url if it exists
|
||||
if self.avatar_asset:
|
||||
return self.avatar_asset.asset_url
|
||||
|
||||
# Return the logo url if it exists
|
||||
if self.avatar:
|
||||
return self.avatar
|
||||
return None
|
||||
|
||||
@property
|
||||
def cover_image_url(self):
|
||||
# Return the logo asset url if it exists
|
||||
if self.cover_image_asset:
|
||||
return self.cover_image_asset.asset_url
|
||||
|
||||
# Return the logo url if it exists
|
||||
if self.cover_image:
|
||||
return self.cover_image
|
||||
return None
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.email = self.email.lower().strip()
|
||||
self.mobile_number = self.mobile_number
|
||||
|
|
@ -182,7 +221,11 @@ class Account(TimeAuditModel):
|
|||
)
|
||||
provider_account_id = models.CharField(max_length=255)
|
||||
provider = models.CharField(
|
||||
choices=(("google", "Google"), ("github", "Github"), ("gitlab", "GitLab")),
|
||||
choices=(
|
||||
("google", "Google"),
|
||||
("github", "Github"),
|
||||
("gitlab", "GitLab"),
|
||||
),
|
||||
)
|
||||
access_token = models.TextField()
|
||||
access_token_expired_at = models.DateTimeField(null=True)
|
||||
|
|
|
|||
|
|
@ -118,7 +118,14 @@ 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.TextField(verbose_name="Logo", blank=True, null=True)
|
||||
logo_asset = models.ForeignKey(
|
||||
"db.FileAsset",
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="workspace_logo",
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
owner = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
|
|
@ -138,6 +145,17 @@ class Workspace(BaseModel):
|
|||
"""Return name of the Workspace"""
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def logo_url(self):
|
||||
# Return the logo asset url if it exists
|
||||
if self.logo_asset:
|
||||
return self.logo_asset.asset_url
|
||||
|
||||
# Return the logo url if it exists
|
||||
if self.logo:
|
||||
return self.logo
|
||||
return None
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Workspace"
|
||||
verbose_name_plural = "Workspaces"
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ class InstanceAdminMeSerializer(BaseSerializer):
|
|||
fields = [
|
||||
"id",
|
||||
"avatar",
|
||||
"avatar_url",
|
||||
"cover_image",
|
||||
"date_joined",
|
||||
"display_name",
|
||||
|
|
|
|||
|
|
@ -72,7 +72,6 @@ INSTALLED_APPS = [
|
|||
"rest_framework",
|
||||
"corsheaders",
|
||||
"django_celery_beat",
|
||||
"storages",
|
||||
]
|
||||
|
||||
# Middlewares
|
||||
|
|
@ -259,7 +258,7 @@ STORAGES = {
|
|||
},
|
||||
}
|
||||
STORAGES["default"] = {
|
||||
"BACKEND": "storages.backends.s3boto3.S3Boto3Storage",
|
||||
"BACKEND": "plane.settings.storage.S3Storage",
|
||||
}
|
||||
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")
|
||||
|
|
@ -384,3 +383,61 @@ SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None)
|
|||
APP_BASE_URL = os.environ.get("APP_BASE_URL")
|
||||
|
||||
HARD_DELETE_AFTER_DAYS = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 60))
|
||||
|
||||
ATTACHMENT_MIME_TYPES = [
|
||||
# Images
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/svg+xml",
|
||||
"image/webp",
|
||||
"image/tiff",
|
||||
"image/bmp",
|
||||
# Documents
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.ms-powerpoint",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"text/plain",
|
||||
"application/rtf",
|
||||
# Audio
|
||||
"audio/mpeg",
|
||||
"audio/wav",
|
||||
"audio/ogg",
|
||||
"audio/midi",
|
||||
"audio/x-midi",
|
||||
"audio/aac",
|
||||
"audio/flac",
|
||||
"audio/x-m4a",
|
||||
# Video
|
||||
"video/mp4",
|
||||
"video/mpeg",
|
||||
"video/ogg",
|
||||
"video/webm",
|
||||
"video/quicktime",
|
||||
"video/x-msvideo",
|
||||
"video/x-ms-wmv",
|
||||
# Archives
|
||||
"application/zip",
|
||||
"application/x-rar-compressed",
|
||||
"application/x-tar",
|
||||
"application/gzip",
|
||||
# 3D Models
|
||||
"model/gltf-binary",
|
||||
"model/gltf+json",
|
||||
"application/octet-stream", # for .obj files, but be cautious
|
||||
# Fonts
|
||||
"font/ttf",
|
||||
"font/otf",
|
||||
"font/woff",
|
||||
"font/woff2",
|
||||
# Other
|
||||
"text/css",
|
||||
"text/javascript",
|
||||
"application/json",
|
||||
"text/xml",
|
||||
"application/xml",
|
||||
]
|
||||
|
|
|
|||
154
apiserver/plane/settings/storage.py
Normal file
154
apiserver/plane/settings/storage.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# Python imports
|
||||
import os
|
||||
|
||||
# Third party imports
|
||||
import boto3
|
||||
from botocore.exceptions import ClientError
|
||||
from urllib.parse import quote
|
||||
|
||||
# Module imports
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from storages.backends.s3boto3 import S3Boto3Storage
|
||||
|
||||
|
||||
class S3Storage(S3Boto3Storage):
|
||||
|
||||
def url(self, name, parameters=None, expire=None, http_method=None):
|
||||
return name
|
||||
|
||||
"""S3 storage class to generate presigned URLs for S3 objects"""
|
||||
|
||||
def __init__(self, request=None):
|
||||
# Get the AWS credentials and bucket name from the environment
|
||||
self.aws_access_key_id = os.environ.get("AWS_ACCESS_KEY_ID")
|
||||
# Use the AWS_SECRET_ACCESS_KEY environment variable for the secret key
|
||||
self.aws_secret_access_key = os.environ.get("AWS_SECRET_ACCESS_KEY")
|
||||
# Use the AWS_S3_BUCKET_NAME environment variable for the bucket name
|
||||
self.aws_storage_bucket_name = os.environ.get("AWS_S3_BUCKET_NAME")
|
||||
# Use the AWS_REGION environment variable for the region
|
||||
self.aws_region = os.environ.get("AWS_REGION")
|
||||
# Use the AWS_S3_ENDPOINT_URL environment variable for the endpoint URL
|
||||
self.aws_s3_endpoint_url = os.environ.get(
|
||||
"AWS_S3_ENDPOINT_URL"
|
||||
) or os.environ.get("MINIO_ENDPOINT_URL")
|
||||
|
||||
if os.environ.get("USE_MINIO") == "1":
|
||||
# Create an S3 client for MinIO
|
||||
self.s3_client = boto3.client(
|
||||
"s3",
|
||||
aws_access_key_id=self.aws_access_key_id,
|
||||
aws_secret_access_key=self.aws_secret_access_key,
|
||||
region_name=self.aws_region,
|
||||
endpoint_url=f"{request.scheme}://{request.get_host()}",
|
||||
config=boto3.session.Config(signature_version="s3v4"),
|
||||
)
|
||||
else:
|
||||
# Create an S3 client
|
||||
self.s3_client = boto3.client(
|
||||
"s3",
|
||||
aws_access_key_id=self.aws_access_key_id,
|
||||
aws_secret_access_key=self.aws_secret_access_key,
|
||||
region_name=self.aws_region,
|
||||
endpoint_url=self.aws_s3_endpoint_url,
|
||||
config=boto3.session.Config(signature_version="s3v4"),
|
||||
)
|
||||
|
||||
def generate_presigned_post(
|
||||
self, object_name, file_type, file_size, expiration=3600
|
||||
):
|
||||
"""Generate a presigned URL to upload an S3 object"""
|
||||
fields = {
|
||||
"Content-Type": file_type,
|
||||
}
|
||||
|
||||
conditions = [
|
||||
{"bucket": self.aws_storage_bucket_name},
|
||||
["content-length-range", 1, file_size],
|
||||
{"Content-Type": file_type},
|
||||
]
|
||||
|
||||
# Add condition for the object name (key)
|
||||
if object_name.startswith("${filename}"):
|
||||
conditions.append(
|
||||
["starts-with", "$key", object_name[: -len("${filename}")]]
|
||||
)
|
||||
else:
|
||||
fields["key"] = object_name
|
||||
conditions.append({"key": object_name})
|
||||
|
||||
# Generate the presigned POST URL
|
||||
try:
|
||||
# Generate a presigned URL for the S3 object
|
||||
response = self.s3_client.generate_presigned_post(
|
||||
Bucket=self.aws_storage_bucket_name,
|
||||
Key=object_name,
|
||||
Fields=fields,
|
||||
Conditions=conditions,
|
||||
ExpiresIn=expiration,
|
||||
)
|
||||
# Handle errors
|
||||
except ClientError as e:
|
||||
print(f"Error generating presigned POST URL: {e}")
|
||||
return None
|
||||
|
||||
return response
|
||||
|
||||
def _get_content_disposition(self, disposition, filename=None):
|
||||
"""Helper method to generate Content-Disposition header value"""
|
||||
if filename:
|
||||
# Encode the filename to handle special characters
|
||||
encoded_filename = quote(filename)
|
||||
return f"{disposition}; filename*=UTF-8''{encoded_filename}"
|
||||
return disposition
|
||||
|
||||
def generate_presigned_url(
|
||||
self,
|
||||
object_name,
|
||||
expiration=3600,
|
||||
http_method="GET",
|
||||
disposition="inline",
|
||||
filename=None,
|
||||
):
|
||||
content_disposition = self._get_content_disposition(
|
||||
disposition, filename
|
||||
)
|
||||
"""Generate a presigned URL to share an S3 object"""
|
||||
try:
|
||||
response = self.s3_client.generate_presigned_url(
|
||||
"get_object",
|
||||
Params={
|
||||
"Bucket": self.aws_storage_bucket_name,
|
||||
"Key": str(object_name),
|
||||
"ResponseContentDisposition": content_disposition,
|
||||
},
|
||||
ExpiresIn=expiration,
|
||||
HttpMethod=http_method,
|
||||
)
|
||||
except ClientError as e:
|
||||
log_exception(e)
|
||||
return None
|
||||
|
||||
# The response contains the presigned URL
|
||||
return response
|
||||
|
||||
def get_object_metadata(self, object_name):
|
||||
"""Get the metadata for an S3 object"""
|
||||
try:
|
||||
response = self.s3_client.head_object(
|
||||
Bucket=self.aws_storage_bucket_name, Key=object_name
|
||||
)
|
||||
except ClientError as e:
|
||||
log_exception(e)
|
||||
return None
|
||||
|
||||
return {
|
||||
"ContentType": response.get("ContentType"),
|
||||
"ContentLength": response.get("ContentLength"),
|
||||
"LastModified": (
|
||||
response.get("LastModified").isoformat()
|
||||
if response.get("LastModified")
|
||||
else None
|
||||
),
|
||||
"ETag": response.get("ETag"),
|
||||
"Metadata": response.get("Metadata", {}),
|
||||
}
|
||||
|
|
@ -22,7 +22,7 @@ from plane.db.models import (
|
|||
CycleIssue,
|
||||
ModuleIssue,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
IssueReaction,
|
||||
CommentReaction,
|
||||
IssueVote,
|
||||
|
|
@ -174,7 +174,7 @@ class IssueLinkSerializer(BaseSerializer):
|
|||
|
||||
class IssueAttachmentSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = IssueAttachment
|
||||
model = FileAsset
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"created_by",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ class UserLiteSerializer(BaseSerializer):
|
|||
"first_name",
|
||||
"last_name",
|
||||
"avatar",
|
||||
"avatar_url",
|
||||
"is_bot",
|
||||
"display_name",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
from .inbox import urlpatterns as inbox_urls
|
||||
from .issue import urlpatterns as issue_urls
|
||||
from .project import urlpatterns as project_urls
|
||||
from .asset import urlpatterns as asset_urls
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
*inbox_urls,
|
||||
*issue_urls,
|
||||
*project_urls,
|
||||
*asset_urls,
|
||||
]
|
||||
|
|
|
|||
32
apiserver/plane/space/urls/asset.py
Normal file
32
apiserver/plane/space/urls/asset.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Django imports
|
||||
from django.urls import path
|
||||
|
||||
# Module imports
|
||||
from plane.space.views import (
|
||||
EntityAssetEndpoint,
|
||||
AssetRestoreEndpoint,
|
||||
EntityBulkAssetEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"assets/v2/anchor/<str:anchor>/",
|
||||
EntityAssetEndpoint.as_view(),
|
||||
name="entity-asset",
|
||||
),
|
||||
path(
|
||||
"assets/v2/anchor/<str:anchor>/<uuid:pk>/",
|
||||
EntityAssetEndpoint.as_view(),
|
||||
name="entity-asset",
|
||||
),
|
||||
path(
|
||||
"assets/v2/anchor/<str:anchor>/restore/<uuid:pk>/",
|
||||
AssetRestoreEndpoint.as_view(),
|
||||
name="asset-restore",
|
||||
),
|
||||
path(
|
||||
"assets/v2/anchor/<str:anchor>/<uuid:entity_id>/bulk/",
|
||||
EntityBulkAssetEndpoint.as_view(),
|
||||
name="entity-bulk-asset",
|
||||
),
|
||||
]
|
||||
|
|
@ -1,8 +1,17 @@
|
|||
# Django imports
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import Q, UUIDField, Value, F, Case, When, JSONField
|
||||
from django.db.models.functions import Coalesce, JSONObject
|
||||
from django.db.models import (
|
||||
Q,
|
||||
UUIDField,
|
||||
Value,
|
||||
F,
|
||||
Case,
|
||||
When,
|
||||
JSONField,
|
||||
CharField,
|
||||
)
|
||||
from django.db.models.functions import Coalesce, JSONObject, Concat
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
|
|
@ -16,6 +25,7 @@ from plane.db.models import (
|
|||
WorkspaceMember,
|
||||
)
|
||||
|
||||
|
||||
def issue_queryset_grouper(queryset, group_by, sub_group_by):
|
||||
|
||||
FIELD_MAPPER = {
|
||||
|
|
@ -98,18 +108,26 @@ def issue_on_results(issues, group_by, sub_group_by):
|
|||
first_name=F("votes__actor__first_name"),
|
||||
last_name=F("votes__actor__last_name"),
|
||||
avatar=F("votes__actor__avatar"),
|
||||
avatar_url=Case(
|
||||
When(
|
||||
votes__actor__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
F("votes__actor__avatar_asset"),
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
default=F("votes__actor__avatar"),
|
||||
output_field=CharField(),
|
||||
),
|
||||
display_name=F("votes__actor__display_name"),
|
||||
)
|
||||
),
|
||||
),
|
||||
),
|
||||
default=None,
|
||||
output_field=JSONField(),
|
||||
),
|
||||
filter=Case(
|
||||
When(votes__isnull=False, then=True),
|
||||
default=False,
|
||||
output_field=JSONField(),
|
||||
),
|
||||
filter=Q(votes__isnull=False),
|
||||
distinct=True,
|
||||
),
|
||||
reaction_items=ArrayAgg(
|
||||
|
|
@ -123,18 +141,30 @@ def issue_on_results(issues, group_by, sub_group_by):
|
|||
first_name=F("issue_reactions__actor__first_name"),
|
||||
last_name=F("issue_reactions__actor__last_name"),
|
||||
avatar=F("issue_reactions__actor__avatar"),
|
||||
display_name=F("issue_reactions__actor__display_name"),
|
||||
avatar_url=Case(
|
||||
When(
|
||||
issue_reactions__actor__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
F(
|
||||
"issue_reactions__actor__avatar_asset"
|
||||
),
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
default=F("issue_reactions__actor__avatar"),
|
||||
output_field=CharField(),
|
||||
),
|
||||
display_name=F(
|
||||
"issue_reactions__actor__display_name"
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
default=None,
|
||||
output_field=JSONField(),
|
||||
),
|
||||
filter=Case(
|
||||
When(issue_reactions__isnull=False, then=True),
|
||||
default=False,
|
||||
output_field=JSONField(),
|
||||
),
|
||||
filter=Q(issue_reactions__isnull=False),
|
||||
distinct=True,
|
||||
),
|
||||
).values(*required_fields, "vote_items", "reaction_items")
|
||||
|
|
|
|||
|
|
@ -23,3 +23,9 @@ from .module import ProjectModulesEndpoint
|
|||
from .state import ProjectStatesEndpoint
|
||||
|
||||
from .label import ProjectLabelsEndpoint
|
||||
|
||||
from .asset import (
|
||||
EntityAssetEndpoint,
|
||||
AssetRestoreEndpoint,
|
||||
EntityBulkAssetEndpoint,
|
||||
)
|
||||
|
|
|
|||
280
apiserver/plane/space/views/asset.py
Normal file
280
apiserver/plane/space/views/asset.py
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
# Python imports
|
||||
import uuid
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView
|
||||
from plane.db.models import DeployBoard, FileAsset
|
||||
from plane.settings.storage import S3Storage
|
||||
|
||||
|
||||
class EntityAssetEndpoint(BaseAPIView):
|
||||
|
||||
def get_permissions(self):
|
||||
if self.request.method == "GET":
|
||||
permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
else:
|
||||
permission_classes = [
|
||||
IsAuthenticated,
|
||||
]
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
def get(self, request, anchor, pk):
|
||||
# Get the deploy board
|
||||
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
|
||||
# Check if the project is published
|
||||
if not deploy_board:
|
||||
return Response(
|
||||
{"error": "Requested resource could not be found."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
# get the asset id
|
||||
asset = FileAsset.objects.get(
|
||||
workspace_id=deploy_board.workspace_id,
|
||||
pk=pk,
|
||||
entity_type__in=[
|
||||
FileAsset.EntityTypeContext.ISSUE_DESCRIPTION,
|
||||
FileAsset.EntityTypeContext.COMMENT_DESCRIPTION,
|
||||
],
|
||||
)
|
||||
|
||||
# Check if the asset is uploaded
|
||||
if not asset.is_uploaded:
|
||||
return Response(
|
||||
{
|
||||
"error": "The requested asset could not be found.",
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
# Get the presigned URL
|
||||
storage = S3Storage(request=request)
|
||||
# Generate a presigned URL to share an S3 object
|
||||
signed_url = storage.generate_presigned_url(
|
||||
object_name=asset.asset.name,
|
||||
)
|
||||
# Redirect to the signed URL
|
||||
return HttpResponseRedirect(signed_url)
|
||||
|
||||
def post(self, request, anchor):
|
||||
# Get the deploy board
|
||||
deploy_board = DeployBoard.objects.filter(
|
||||
anchor=anchor, entity_name="project"
|
||||
).first()
|
||||
# Check if the project is published
|
||||
if not deploy_board:
|
||||
return Response(
|
||||
{"error": "Project is not published"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
# Get the asset
|
||||
name = request.data.get("name")
|
||||
type = request.data.get("type", "image/jpeg")
|
||||
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
|
||||
entity_type = request.data.get("entity_type", "")
|
||||
entity_identifier = request.data.get("entity_identifier")
|
||||
|
||||
# Check if the entity type is allowed
|
||||
if entity_type not in FileAsset.EntityTypeContext.values:
|
||||
return Response(
|
||||
{
|
||||
"error": "Invalid entity type.",
|
||||
"status": False,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if the file type is allowed
|
||||
allowed_types = ["image/jpeg", "image/png", "image/webp"]
|
||||
if type not in allowed_types:
|
||||
return Response(
|
||||
{
|
||||
"error": "Invalid file type. Only JPEG and PNG files are allowed.",
|
||||
"status": False,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# asset key
|
||||
asset_key = f"{deploy_board.workspace_id}/{uuid.uuid4().hex}-{name}"
|
||||
|
||||
# Create a File Asset
|
||||
asset = FileAsset.objects.create(
|
||||
attributes={
|
||||
"name": name,
|
||||
"type": type,
|
||||
"size": size,
|
||||
},
|
||||
asset=asset_key,
|
||||
size=size,
|
||||
workspace=deploy_board.workspace,
|
||||
created_by=request.user,
|
||||
entity_type=entity_type,
|
||||
project_id=deploy_board.project_id,
|
||||
comment_id=entity_identifier,
|
||||
)
|
||||
|
||||
# Get the presigned URL
|
||||
storage = S3Storage(request=request)
|
||||
# Generate a presigned URL to share an S3 object
|
||||
presigned_url = storage.generate_presigned_post(
|
||||
object_name=asset_key,
|
||||
file_type=type,
|
||||
file_size=size,
|
||||
)
|
||||
# Return the presigned URL
|
||||
return Response(
|
||||
{
|
||||
"upload_data": presigned_url,
|
||||
"asset_id": str(asset.id),
|
||||
"asset_url": asset.asset_url,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def patch(self, request, anchor, pk):
|
||||
# Get the deploy board
|
||||
deploy_board = DeployBoard.objects.filter(
|
||||
anchor=anchor, entity_name="project"
|
||||
).first()
|
||||
# Check if the project is published
|
||||
if not deploy_board:
|
||||
return Response(
|
||||
{"error": "Project is not published"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
# get the asset id
|
||||
asset = FileAsset.objects.get(id=pk, workspace=deploy_board.workspace)
|
||||
storage = S3Storage(request=request)
|
||||
# get the storage metadata
|
||||
asset.is_uploaded = True
|
||||
# get the storage metadata
|
||||
if asset.storage_metadata is None:
|
||||
asset.storage_metadata = storage.get_object_metadata(
|
||||
object_name=asset.asset.name
|
||||
)
|
||||
|
||||
# update the attributes
|
||||
asset.attributes = request.data.get("attributes", asset.attributes)
|
||||
# save the asset
|
||||
asset.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def delete(self, request, anchor, pk):
|
||||
# Get the deploy board
|
||||
deploy_board = DeployBoard.objects.filter(
|
||||
anchor=anchor, entity_name="project"
|
||||
).first()
|
||||
# Check if the project is published
|
||||
if not deploy_board:
|
||||
return Response(
|
||||
{"error": "Project is not published"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
# Get the asset
|
||||
asset = FileAsset.objects.get(
|
||||
id=pk,
|
||||
workspace=deploy_board.workspace,
|
||||
project_id=deploy_board.project_id,
|
||||
)
|
||||
# Check deleted assets
|
||||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
# Save the asset
|
||||
asset.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class AssetRestoreEndpoint(BaseAPIView):
|
||||
"""Endpoint to restore a deleted assets."""
|
||||
|
||||
def post(self, request, anchor, asset_id):
|
||||
# Get the deploy board
|
||||
deploy_board = DeployBoard.objects.filter(
|
||||
anchor=anchor, entity_name="project"
|
||||
).first()
|
||||
# Check if the project is published
|
||||
if not deploy_board:
|
||||
return Response(
|
||||
{"error": "Project is not published"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
# Get the asset
|
||||
asset = FileAsset.all_objects.get(
|
||||
id=asset_id, workspace=deploy_board.workspace
|
||||
)
|
||||
asset.is_deleted = False
|
||||
asset.deleted_at = None
|
||||
asset.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class EntityBulkAssetEndpoint(BaseAPIView):
|
||||
"""Endpoint to bulk update assets."""
|
||||
|
||||
def post(self, request, anchor, entity_id):
|
||||
# Get the deploy board
|
||||
deploy_board = DeployBoard.objects.filter(
|
||||
anchor=anchor, entity_name="project"
|
||||
).first()
|
||||
# Check if the project is published
|
||||
if not deploy_board:
|
||||
return Response(
|
||||
{"error": "Project is not published"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
asset_ids = request.data.get("asset_ids", [])
|
||||
|
||||
# Check if the asset ids are provided
|
||||
if not asset_ids:
|
||||
return Response(
|
||||
{
|
||||
"error": "No asset ids provided.",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# get the asset id
|
||||
assets = FileAsset.objects.filter(
|
||||
id__in=asset_ids,
|
||||
workspace=deploy_board.workspace,
|
||||
project_id=deploy_board.project_id,
|
||||
)
|
||||
|
||||
asset = assets.first()
|
||||
|
||||
# Check if the asset is uploaded
|
||||
if not asset:
|
||||
return Response(
|
||||
{
|
||||
"error": "The requested asset could not be found.",
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
# Check if the entity type is allowed
|
||||
if (
|
||||
asset.entity_type
|
||||
== FileAsset.EntityTypeContext.COMMENT_DESCRIPTION
|
||||
):
|
||||
# update the attributes
|
||||
assets.update(
|
||||
comment_id=entity_id,
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
|
@ -17,7 +17,7 @@ from plane.db.models import (
|
|||
Issue,
|
||||
State,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
DeployBoard,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
|
|
@ -95,8 +95,9 @@ class InboxIssuePublicViewSet(BaseViewSet):
|
|||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ from django.db.models import (
|
|||
Value,
|
||||
OuterRef,
|
||||
Func,
|
||||
CharField,
|
||||
)
|
||||
from django.db.models.functions import Concat
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
|
|
@ -59,7 +61,7 @@ from plane.db.models import (
|
|||
DeployBoard,
|
||||
IssueVote,
|
||||
ProjectPublicMember,
|
||||
IssueAttachment,
|
||||
FileAsset,
|
||||
)
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
|
@ -112,8 +114,9 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
|
|||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
|
|
@ -746,6 +749,26 @@ class IssueRetrievePublicEndpoint(BaseAPIView):
|
|||
first_name=F("votes__actor__first_name"),
|
||||
last_name=F("votes__actor__last_name"),
|
||||
avatar=F("votes__actor__avatar"),
|
||||
avatar_url=Case(
|
||||
When(
|
||||
votes__actor__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value(
|
||||
"/api/assets/v2/static/"
|
||||
),
|
||||
F(
|
||||
"votes__actor__avatar_asset"
|
||||
),
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
When(
|
||||
votes__actor__avatar_asset__isnull=True,
|
||||
then=F("votes__actor__avatar"),
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=CharField(),
|
||||
),
|
||||
display_name=F(
|
||||
"votes__actor__display_name"
|
||||
),
|
||||
|
|
@ -777,6 +800,26 @@ class IssueRetrievePublicEndpoint(BaseAPIView):
|
|||
"issue_reactions__actor__last_name"
|
||||
),
|
||||
avatar=F("issue_reactions__actor__avatar"),
|
||||
avatar_url=Case(
|
||||
When(
|
||||
votes__actor__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value(
|
||||
"/api/assets/v2/static/"
|
||||
),
|
||||
F(
|
||||
"votes__actor__avatar_asset"
|
||||
),
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
When(
|
||||
votes__actor__avatar_asset__isnull=True,
|
||||
then=F("votes__actor__avatar"),
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=CharField(),
|
||||
),
|
||||
display_name=F(
|
||||
"issue_reactions__actor__display_name"
|
||||
),
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn
|
|||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
|
|
@ -38,6 +39,7 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn
|
|||
const { editor, hasServerConnectionFailed, hasServerSynced } = useReadOnlyCollaborativeEditor({
|
||||
editorClassName,
|
||||
extensions,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { getEditorClassNames } from "@/helpers/common";
|
|||
// hooks
|
||||
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig } from "@/types";
|
||||
import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig, TFileHandler } from "@/types";
|
||||
|
||||
interface IDocumentReadOnlyEditor {
|
||||
id: string;
|
||||
|
|
@ -19,6 +19,7 @@ interface IDocumentReadOnlyEditor {
|
|||
displayConfig?: TDisplayConfig;
|
||||
editorClassName?: string;
|
||||
embedHandler: any;
|
||||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||
tabIndex?: number;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
mentionHandler: {
|
||||
|
|
@ -33,6 +34,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
|||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
fileHandler,
|
||||
id,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
|
|
@ -51,6 +53,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
|||
const editor = useReadOnlyEditor({
|
||||
editorClassName,
|
||||
extensions,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
initialValue,
|
||||
|
|
|
|||
|
|
@ -14,14 +14,16 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
|
|||
containerClassName,
|
||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editorClassName = "",
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
id,
|
||||
initialValue,
|
||||
forwardedRef,
|
||||
mentionHandler,
|
||||
} = props;
|
||||
|
||||
const editor = useReadOnlyEditor({
|
||||
editorClassName,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
initialValue,
|
||||
mentionHandler,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ type CustomImageBlockProps = CustomImageNodeViewProps & {
|
|||
setFailedToLoadImage: (isError: boolean) => void;
|
||||
editorContainer: HTMLDivElement | null;
|
||||
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
|
||||
src: string;
|
||||
};
|
||||
|
||||
export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
|
|
@ -55,9 +56,10 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
|||
getPos,
|
||||
editor,
|
||||
editorContainer,
|
||||
src: remoteImageSrc,
|
||||
setEditorContainer,
|
||||
} = props;
|
||||
const { src: remoteImageSrc, width, height, aspectRatio } = node.attrs;
|
||||
const { width, height, aspectRatio } = node.attrs;
|
||||
// states
|
||||
const [size, setSize] = useState<Size>({
|
||||
width: ensurePixelString(width, "35%"),
|
||||
|
|
@ -206,7 +208,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
|||
// show the image resizer only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
|
||||
const showImageResizer = editor.isEditable && remoteImageSrc && initialResizeComplete;
|
||||
// show the preview image from the file system if the remote image's src is not set
|
||||
const displayedImageSrc = remoteImageSrc ?? imageFromFileSystem;
|
||||
const displayedImageSrc = remoteImageSrc || imageFromFileSystem;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ export const CustomImageNode = (props: CustomImageNodeViewProps) => {
|
|||
imageFromFileSystem={imageFromFileSystem}
|
||||
editorContainer={editorContainer}
|
||||
editor={editor}
|
||||
// @ts-expect-error function not expected here, but will still work
|
||||
src={editor?.commands?.getImageSource?.(node.attrs.src)}
|
||||
getPos={getPos}
|
||||
node={node}
|
||||
setEditorContainer={setEditorContainer}
|
||||
|
|
@ -67,6 +69,7 @@ export const CustomImageNode = (props: CustomImageNodeViewProps) => {
|
|||
failedToLoadImage={failedToLoadImage}
|
||||
getPos={getPos}
|
||||
loadImageFromFileSystem={setImageFromFileSystem}
|
||||
maxFileSize={editor.storage.imageComponent.maxFileSize}
|
||||
node={node}
|
||||
setIsUploaded={setIsUploaded}
|
||||
selected={selected}
|
||||
|
|
|
|||
|
|
@ -10,33 +10,34 @@ import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/
|
|||
import { getImageComponentImageFileMap, ImageAttributes } from "@/extensions/custom-image";
|
||||
|
||||
export const CustomImageUploader = (props: {
|
||||
failedToLoadImage: boolean;
|
||||
editor: Editor;
|
||||
selected: boolean;
|
||||
failedToLoadImage: boolean;
|
||||
getPos: () => number;
|
||||
loadImageFromFileSystem: (file: string) => void;
|
||||
setIsUploaded: (isUploaded: boolean) => void;
|
||||
maxFileSize: number;
|
||||
node: ProsemirrorNode & {
|
||||
attrs: ImageAttributes;
|
||||
};
|
||||
selected: boolean;
|
||||
setIsUploaded: (isUploaded: boolean) => void;
|
||||
updateAttributes: (attrs: Record<string, any>) => void;
|
||||
getPos: () => number;
|
||||
}) => {
|
||||
const {
|
||||
selected,
|
||||
failedToLoadImage,
|
||||
editor,
|
||||
failedToLoadImage,
|
||||
getPos,
|
||||
loadImageFromFileSystem,
|
||||
maxFileSize,
|
||||
node,
|
||||
selected,
|
||||
setIsUploaded,
|
||||
updateAttributes,
|
||||
getPos,
|
||||
} = props;
|
||||
// ref
|
||||
// refs
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const hasTriggeredFilePickerRef = useRef(false);
|
||||
// derived values
|
||||
const imageEntityId = node.attrs.id;
|
||||
|
||||
const imageComponentImageFileMap = useMemo(() => getImageComponentImageFileMap(editor), [editor]);
|
||||
|
||||
const onUpload = useCallback(
|
||||
|
|
@ -71,11 +72,17 @@ export const CustomImageUploader = (props: {
|
|||
[imageComponentImageFileMap, imageEntityId, updateAttributes, getPos]
|
||||
);
|
||||
// hooks
|
||||
const { uploading: isImageBeingUploaded, uploadFile } = useUploader({ onUpload, editor, loadImageFromFileSystem });
|
||||
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({
|
||||
uploader: uploadFile,
|
||||
const { uploading: isImageBeingUploaded, uploadFile } = useUploader({
|
||||
editor,
|
||||
loadImageFromFileSystem,
|
||||
maxFileSize,
|
||||
onUpload,
|
||||
});
|
||||
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({
|
||||
editor,
|
||||
maxFileSize,
|
||||
pos: getPos(),
|
||||
uploader: uploadFile,
|
||||
});
|
||||
|
||||
// the meta data of the image component
|
||||
|
|
@ -102,11 +109,17 @@ export const CustomImageUploader = (props: {
|
|||
const onFileChange = useCallback(
|
||||
async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
const fileList = e.target.files;
|
||||
if (!fileList) {
|
||||
const filesList = e.target.files;
|
||||
if (!filesList) {
|
||||
return;
|
||||
}
|
||||
await uploadFirstImageAndInsertRemaining(editor, fileList, getPos(), uploadFile);
|
||||
await uploadFirstImageAndInsertRemaining({
|
||||
editor,
|
||||
filesList,
|
||||
maxFileSize,
|
||||
pos: getPos(),
|
||||
uploader: uploadFile,
|
||||
});
|
||||
},
|
||||
[uploadFile, editor, getPos]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ declare module "@tiptap/core" {
|
|||
imageComponent: {
|
||||
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
|
||||
uploadImage: (file: File) => () => Promise<string> | undefined;
|
||||
getImageSource?: (path: string) => () => string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -36,7 +37,13 @@ export interface UploadImageExtensionStorage {
|
|||
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
|
||||
|
||||
export const CustomImageExtension = (props: TFileHandler) => {
|
||||
const { upload, delete: deleteImage, restore: restoreImage } = props;
|
||||
const {
|
||||
getAssetSrc,
|
||||
upload,
|
||||
delete: deleteImage,
|
||||
restore: restoreImage,
|
||||
validation: { maxFileSize },
|
||||
} = props;
|
||||
|
||||
return Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
|
||||
name: "imageComponent",
|
||||
|
|
@ -87,8 +94,7 @@ export const CustomImageExtension = (props: TFileHandler) => {
|
|||
});
|
||||
imageSources.forEach(async (src) => {
|
||||
try {
|
||||
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
|
||||
await restoreImage(assetUrlWithWorkspaceId);
|
||||
await restoreImage(src);
|
||||
} catch (error) {
|
||||
console.error("Error restoring image: ", error);
|
||||
}
|
||||
|
|
@ -114,6 +120,7 @@ export const CustomImageExtension = (props: TFileHandler) => {
|
|||
fileMap: new Map(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
uploadInProgress: false,
|
||||
maxFileSize,
|
||||
};
|
||||
},
|
||||
|
||||
|
|
@ -123,7 +130,13 @@ export const CustomImageExtension = (props: TFileHandler) => {
|
|||
(props: { file?: File; pos?: number; event: "insert" | "drop" }) =>
|
||||
({ commands }) => {
|
||||
// Early return if there's an invalid file being dropped
|
||||
if (props?.file && !isFileValid(props.file)) {
|
||||
if (
|
||||
props?.file &&
|
||||
!isFileValid({
|
||||
file: props.file,
|
||||
maxFileSize,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -166,6 +179,7 @@ export const CustomImageExtension = (props: TFileHandler) => {
|
|||
const fileUrl = await upload(file);
|
||||
return fileUrl;
|
||||
},
|
||||
getImageSource: (path: string) => () => getAssetSrc(path),
|
||||
};
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -3,9 +3,13 @@ import { Image } from "@tiptap/extension-image";
|
|||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// components
|
||||
import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions/custom-image";
|
||||
// types
|
||||
import { TFileHandler } from "@/types";
|
||||
|
||||
export const CustomReadOnlyImageExtension = () =>
|
||||
Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
|
||||
export const CustomReadOnlyImageExtension = (props: Pick<TFileHandler, "getAssetSrc">) => {
|
||||
const { getAssetSrc } = props;
|
||||
|
||||
return Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
|
||||
name: "imageComponent",
|
||||
selectable: false,
|
||||
group: "block",
|
||||
|
|
@ -51,7 +55,14 @@ export const CustomReadOnlyImageExtension = () =>
|
|||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
getImageSource: (path: string) => () => getAssetSrc(path),
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNode);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -31,16 +31,11 @@ import {
|
|||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
// types
|
||||
import { DeleteImage, IMentionHighlight, IMentionSuggestion, RestoreImage, UploadImage } from "@/types";
|
||||
import { IMentionHighlight, IMentionSuggestion, TFileHandler } from "@/types";
|
||||
|
||||
type TArguments = {
|
||||
enableHistory: boolean;
|
||||
fileConfig: {
|
||||
deleteFile: DeleteImage;
|
||||
restoreFile: RestoreImage;
|
||||
cancelUploadImage?: () => void;
|
||||
uploadFile: UploadImage;
|
||||
};
|
||||
fileHandler: TFileHandler;
|
||||
mentionConfig: {
|
||||
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
mentionHighlights?: () => Promise<IMentionHighlight[]>;
|
||||
|
|
@ -49,13 +44,10 @@ type TArguments = {
|
|||
tabIndex?: number;
|
||||
};
|
||||
|
||||
export const CoreEditorExtensions = ({
|
||||
enableHistory,
|
||||
fileConfig: { deleteFile, restoreFile, cancelUploadImage, uploadFile },
|
||||
mentionConfig,
|
||||
placeholder,
|
||||
tabIndex,
|
||||
}: TArguments) => [
|
||||
export const CoreEditorExtensions = (args: TArguments) => {
|
||||
const { enableHistory, fileHandler, mentionConfig, placeholder, tabIndex } = args;
|
||||
|
||||
return [
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
|
|
@ -102,17 +94,12 @@ export const CoreEditorExtensions = ({
|
|||
},
|
||||
}),
|
||||
CustomTypographyExtension,
|
||||
ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({
|
||||
ImageExtension(fileHandler).configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md",
|
||||
},
|
||||
}),
|
||||
CustomImageExtension({
|
||||
delete: deleteFile,
|
||||
restore: restoreFile,
|
||||
upload: uploadFile,
|
||||
cancel: cancelUploadImage ?? (() => {}),
|
||||
}),
|
||||
CustomImageExtension(fileHandler),
|
||||
TiptapUnderline,
|
||||
TextStyle,
|
||||
TaskList.configure({
|
||||
|
|
@ -171,3 +158,4 @@ export const CoreEditorExtensions = ({
|
|||
CustomTextColorExtension,
|
||||
CustomBackgroundColorExtension,
|
||||
];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,12 +5,19 @@ import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-par
|
|||
// plugins
|
||||
import { ImageExtensionStorage, TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image";
|
||||
// types
|
||||
import { DeleteImage, RestoreImage } from "@/types";
|
||||
import { TFileHandler } from "@/types";
|
||||
// extensions
|
||||
import { CustomImageNode } from "@/extensions";
|
||||
|
||||
export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreImage, cancelUploadImage?: () => void) =>
|
||||
ImageExt.extend<any, ImageExtensionStorage>({
|
||||
export const ImageExtension = (fileHandler: TFileHandler) => {
|
||||
const {
|
||||
delete: deleteImage,
|
||||
getAssetSrc,
|
||||
restore: restoreImage,
|
||||
validation: { maxFileSize },
|
||||
} = fileHandler;
|
||||
|
||||
return ImageExt.extend<any, ImageExtensionStorage>({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
|
||||
|
|
@ -33,8 +40,7 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm
|
|||
});
|
||||
imageSources.forEach(async (src) => {
|
||||
try {
|
||||
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
|
||||
await restoreImage(assetUrlWithWorkspaceId);
|
||||
await restoreImage(src);
|
||||
} catch (error) {
|
||||
console.error("Error restoring image: ", error);
|
||||
}
|
||||
|
|
@ -46,6 +52,7 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm
|
|||
return {
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
uploadInProgress: false,
|
||||
maxFileSize,
|
||||
};
|
||||
},
|
||||
|
||||
|
|
@ -61,8 +68,15 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm
|
|||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
getImageSource: (path: string) => () => getAssetSrc(path),
|
||||
};
|
||||
},
|
||||
|
||||
// render custom image node
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNode);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,8 +2,13 @@ import Image from "@tiptap/extension-image";
|
|||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// extensions
|
||||
import { CustomImageNode } from "@/extensions";
|
||||
// types
|
||||
import { TFileHandler } from "@/types";
|
||||
|
||||
export const ReadOnlyImageExtension = Image.extend({
|
||||
export const ReadOnlyImageExtension = (props: Pick<TFileHandler, "getAssetSrc">) => {
|
||||
const { getAssetSrc } = props;
|
||||
|
||||
return Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
|
|
@ -15,7 +20,15 @@ export const ReadOnlyImageExtension = Image.extend({
|
|||
},
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
getImageSource: (path: string) => () => getAssetSrc(path),
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNode);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -27,11 +27,19 @@ import {
|
|||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
// types
|
||||
import { IMentionHighlight } from "@/types";
|
||||
import { IMentionHighlight, TFileHandler } from "@/types";
|
||||
|
||||
export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
||||
type Props = {
|
||||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||
mentionConfig: {
|
||||
mentionHighlights?: () => Promise<IMentionHighlight[]>;
|
||||
}) => [
|
||||
};
|
||||
};
|
||||
|
||||
export const CoreReadOnlyEditorExtensions = (props: Props) => {
|
||||
const { fileHandler, mentionConfig } = props;
|
||||
|
||||
return [
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
|
|
@ -73,12 +81,16 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
|||
},
|
||||
}),
|
||||
CustomTypographyExtension,
|
||||
ReadOnlyImageExtension.configure({
|
||||
ReadOnlyImageExtension({
|
||||
getAssetSrc: fileHandler.getAssetSrc,
|
||||
}).configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md",
|
||||
},
|
||||
}),
|
||||
CustomReadOnlyImageExtension(),
|
||||
CustomReadOnlyImageExtension({
|
||||
getAssetSrc: fileHandler.getAssetSrc,
|
||||
}),
|
||||
TiptapUnderline,
|
||||
TextStyle,
|
||||
TaskList.configure({
|
||||
|
|
@ -115,3 +127,4 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
|||
CustomBackgroundColorExtension,
|
||||
HeadingListExtension,
|
||||
];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -75,12 +75,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
|||
extensions: [
|
||||
...CoreEditorExtensions({
|
||||
enableHistory,
|
||||
fileConfig: {
|
||||
uploadFile: fileHandler.upload,
|
||||
deleteFile: fileHandler.delete,
|
||||
restoreFile: fileHandler.restore,
|
||||
cancelUploadImage: fileHandler.cancel,
|
||||
},
|
||||
fileHandler,
|
||||
mentionConfig: {
|
||||
mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve<IMentionSuggestion[]>([])),
|
||||
mentionHighlights: mentionHandler.highlights,
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
import { DragEvent, useCallback, useEffect, useState } from "react";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { isFileValid } from "@/plugins/image";
|
||||
// extensions
|
||||
import { insertImagesSafely } from "@/extensions/drop";
|
||||
// plugins
|
||||
import { isFileValid } from "@/plugins/image";
|
||||
|
||||
export const useUploader = ({
|
||||
onUpload,
|
||||
editor,
|
||||
loadImageFromFileSystem,
|
||||
}: {
|
||||
onUpload: (url: string) => void;
|
||||
type TUploaderArgs = {
|
||||
editor: Editor;
|
||||
loadImageFromFileSystem: (file: string) => void;
|
||||
}) => {
|
||||
maxFileSize: number;
|
||||
onUpload: (url: string) => void;
|
||||
};
|
||||
|
||||
export const useUploader = (args: TUploaderArgs) => {
|
||||
const { editor, loadImageFromFileSystem, maxFileSize, onUpload } = args;
|
||||
// states
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const uploadFile = useCallback(
|
||||
|
|
@ -23,7 +26,10 @@ export const useUploader = ({
|
|||
setUploading(true);
|
||||
const fileNameTrimmed = trimFileName(file.name);
|
||||
const fileWithTrimmedName = new File([file], fileNameTrimmed, { type: file.type });
|
||||
const isValid = isFileValid(fileWithTrimmedName);
|
||||
const isValid = isFileValid({
|
||||
file: fileWithTrimmedName,
|
||||
maxFileSize,
|
||||
});
|
||||
if (!isValid) {
|
||||
setImageUploadInProgress(false);
|
||||
return;
|
||||
|
|
@ -64,15 +70,16 @@ export const useUploader = ({
|
|||
return { uploading, uploadFile };
|
||||
};
|
||||
|
||||
export const useDropZone = ({
|
||||
uploader,
|
||||
editor,
|
||||
pos,
|
||||
}: {
|
||||
uploader: (file: File) => Promise<void>;
|
||||
type TDropzoneArgs = {
|
||||
editor: Editor;
|
||||
maxFileSize: number;
|
||||
pos: number;
|
||||
}) => {
|
||||
uploader: (file: File) => Promise<void>;
|
||||
};
|
||||
|
||||
export const useDropZone = (args: TDropzoneArgs) => {
|
||||
const { editor, maxFileSize, pos, uploader } = args;
|
||||
// states
|
||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||
const [draggedInside, setDraggedInside] = useState<boolean>(false);
|
||||
|
||||
|
|
@ -101,8 +108,14 @@ export const useDropZone = ({
|
|||
if (e.dataTransfer.files.length === 0) {
|
||||
return;
|
||||
}
|
||||
const fileList = e.dataTransfer.files;
|
||||
await uploadFirstImageAndInsertRemaining(editor, fileList, pos, uploader);
|
||||
const filesList = e.dataTransfer.files;
|
||||
await uploadFirstImageAndInsertRemaining({
|
||||
editor,
|
||||
filesList,
|
||||
maxFileSize,
|
||||
pos,
|
||||
uploader,
|
||||
});
|
||||
},
|
||||
[uploader, editor, pos]
|
||||
);
|
||||
|
|
@ -129,22 +142,33 @@ function trimFileName(fileName: string, maxLength = 100) {
|
|||
return fileName;
|
||||
}
|
||||
|
||||
type TMultipleImagesArgs = {
|
||||
editor: Editor;
|
||||
filesList: FileList;
|
||||
maxFileSize: number;
|
||||
pos: number;
|
||||
uploader: (file: File) => Promise<void>;
|
||||
};
|
||||
|
||||
// Upload the first image and insert the remaining images for uploading multiple image
|
||||
// post insertion of image-component
|
||||
export async function uploadFirstImageAndInsertRemaining(
|
||||
editor: Editor,
|
||||
fileList: FileList,
|
||||
pos: number,
|
||||
uploaderFn: (file: File) => Promise<void>
|
||||
) {
|
||||
export async function uploadFirstImageAndInsertRemaining(args: TMultipleImagesArgs) {
|
||||
const { editor, filesList, maxFileSize, pos, uploader } = args;
|
||||
const filteredFiles: File[] = [];
|
||||
for (let i = 0; i < fileList.length; i += 1) {
|
||||
const item = fileList.item(i);
|
||||
if (item && item.type.indexOf("image") !== -1 && isFileValid(item)) {
|
||||
for (let i = 0; i < filesList.length; i += 1) {
|
||||
const item = filesList.item(i);
|
||||
if (
|
||||
item &&
|
||||
item.type.indexOf("image") !== -1 &&
|
||||
isFileValid({
|
||||
file: item,
|
||||
maxFileSize,
|
||||
})
|
||||
) {
|
||||
filteredFiles.push(item);
|
||||
}
|
||||
}
|
||||
if (filteredFiles.length !== fileList.length) {
|
||||
if (filteredFiles.length !== filesList.length) {
|
||||
console.warn("Some files were not images and have been ignored.");
|
||||
}
|
||||
if (filteredFiles.length === 0) {
|
||||
|
|
@ -154,7 +178,7 @@ export async function uploadFirstImageAndInsertRemaining(
|
|||
|
||||
// Upload the first image
|
||||
const firstFile = filteredFiles[0];
|
||||
uploaderFn(firstFile);
|
||||
uploader(firstFile);
|
||||
|
||||
// Insert the remaining images
|
||||
const remainingFiles = filteredFiles.slice(1);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit
|
|||
editorClassName,
|
||||
editorProps = {},
|
||||
extensions,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
|
|
@ -74,6 +75,7 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit
|
|||
document: provider.document,
|
||||
}),
|
||||
],
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
mentionHandler,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
|
|||
// props
|
||||
import { CoreReadOnlyEditorProps } from "@/props";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi, IMentionHighlight } from "@/types";
|
||||
import { EditorReadOnlyRefApi, IMentionHighlight, TFileHandler } from "@/types";
|
||||
|
||||
interface CustomReadOnlyEditorProps {
|
||||
initialValue?: string;
|
||||
|
|
@ -19,6 +19,7 @@ interface CustomReadOnlyEditorProps {
|
|||
forwardedRef?: MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
extensions?: any;
|
||||
editorProps?: EditorProps;
|
||||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
|
|
@ -33,6 +34,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
|||
forwardedRef,
|
||||
extensions = [],
|
||||
editorProps = {},
|
||||
fileHandler,
|
||||
handleEditorReady,
|
||||
mentionHandler,
|
||||
provider,
|
||||
|
|
@ -52,7 +54,10 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
|||
},
|
||||
extensions: [
|
||||
...CoreReadOnlyEditorExtensions({
|
||||
mentionConfig: {
|
||||
mentionHighlights: mentionHandler.highlights,
|
||||
},
|
||||
fileHandler,
|
||||
}),
|
||||
...extensions,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -47,10 +47,9 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag
|
|||
});
|
||||
|
||||
async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise<void> {
|
||||
try {
|
||||
if (!src) return;
|
||||
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
|
||||
await deleteImage(assetUrlWithWorkspaceId);
|
||||
try {
|
||||
await deleteImage(src);
|
||||
} catch (error) {
|
||||
console.error("Error deleting image: ", error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,10 +48,9 @@ export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: Restor
|
|||
});
|
||||
|
||||
async function onNodeRestored(src: string, restoreImage: RestoreImage): Promise<void> {
|
||||
try {
|
||||
if (!src) return;
|
||||
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
|
||||
await restoreImage(assetUrlWithWorkspaceId);
|
||||
try {
|
||||
await restoreImage(src);
|
||||
} catch (error) {
|
||||
console.error("Error restoring image: ", error);
|
||||
throw error;
|
||||
|
|
|
|||
|
|
@ -1,25 +1,26 @@
|
|||
export function isFileValid(file: File, showAlert = true): boolean {
|
||||
type TArgs = {
|
||||
file: File;
|
||||
maxFileSize: number;
|
||||
};
|
||||
|
||||
export const isFileValid = (args: TArgs): boolean => {
|
||||
const { file, maxFileSize } = args;
|
||||
|
||||
if (!file) {
|
||||
if (showAlert) {
|
||||
alert("No file selected. Please select a file to upload.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const allowedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp"];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
if (showAlert) {
|
||||
alert("Invalid file type. Please select a JPEG, JPG, PNG, or WEBP image file.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
if (showAlert) {
|
||||
alert("File size too large. Please select a file smaller than 5MB.");
|
||||
}
|
||||
if (file.size > maxFileSize) {
|
||||
alert(`File size too large. Please select a file smaller than ${maxFileSize / 1024 / 1024}MB.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -44,5 +44,6 @@ export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & {
|
|||
};
|
||||
|
||||
export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & {
|
||||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,18 @@
|
|||
import { DeleteImage, RestoreImage, UploadImage } from "@/types";
|
||||
|
||||
export type TFileHandler = {
|
||||
getAssetSrc: (path: string) => string;
|
||||
cancel: () => void;
|
||||
delete: DeleteImage;
|
||||
upload: UploadImage;
|
||||
restore: RestoreImage;
|
||||
validation: {
|
||||
/**
|
||||
* @description max file size in bytes
|
||||
* @example enter 5242880( 5* 1024 * 1024) for 5MB
|
||||
*/
|
||||
maxFileSize: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type TEditorFontStyle = "sans-serif" | "serif" | "monospace";
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ export interface IReadOnlyEditorProps {
|
|||
containerClassName?: string;
|
||||
displayConfig?: TDisplayConfig;
|
||||
editorClassName?: string;
|
||||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
id: string;
|
||||
initialValue: string;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
|
||||
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<void>;
|
||||
|
||||
export type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
|
||||
export type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise<void>;
|
||||
|
||||
export type UploadImage = (file: File) => Promise<string>;
|
||||
|
|
|
|||
6
packages/types/src/analytics.d.ts
vendored
6
packages/types/src/analytics.d.ts
vendored
|
|
@ -20,7 +20,7 @@ export interface IAnalyticsData {
|
|||
}
|
||||
|
||||
export interface IAnalyticsAssigneeDetails {
|
||||
assignees__avatar: string | null;
|
||||
assignees__avatar_url: string | null;
|
||||
assignees__display_name: string | null;
|
||||
assignees__first_name: string;
|
||||
assignees__id: string | null;
|
||||
|
|
@ -87,7 +87,7 @@ export interface IExportAnalyticsFormData {
|
|||
}
|
||||
|
||||
export interface IDefaultAnalyticsUser {
|
||||
assignees__avatar: string | null;
|
||||
assignees__avatar_url: string | null;
|
||||
assignees__first_name: string;
|
||||
assignees__last_name: string;
|
||||
assignees__display_name: string;
|
||||
|
|
@ -99,7 +99,7 @@ export interface IDefaultAnalyticsResponse {
|
|||
issue_completed_month_wise: { month: number; count: number }[];
|
||||
most_issue_closed_user: IDefaultAnalyticsUser[];
|
||||
most_issue_created_user: {
|
||||
created_by__avatar: string | null;
|
||||
created_by__avatar_url: string | null;
|
||||
created_by__first_name: string;
|
||||
created_by__last_name: string;
|
||||
created_by__display_name: string;
|
||||
|
|
|
|||
17
packages/types/src/current-user/accounts.d.ts
vendored
17
packages/types/src/current-user/accounts.d.ts
vendored
|
|
@ -1,17 +0,0 @@
|
|||
export type TCurrentUserAccount = {
|
||||
id: string | undefined;
|
||||
|
||||
user: string | undefined;
|
||||
|
||||
provider_account_id: string | undefined;
|
||||
provider: "google" | "github" | "gitlab" | string | undefined;
|
||||
access_token: string | undefined;
|
||||
access_token_expired_at: Date | undefined;
|
||||
refresh_token: string | undefined;
|
||||
refresh_token_expired_at: Date | undefined;
|
||||
last_connected_at: Date | undefined;
|
||||
metadata: object | undefined;
|
||||
|
||||
created_at: Date | undefined;
|
||||
updated_at: Date | undefined;
|
||||
};
|
||||
|
|
@ -1,3 +1 @@
|
|||
export * from "./user";
|
||||
export * from "./profile";
|
||||
export * from "./accounts";
|
||||
|
|
|
|||
30
packages/types/src/current-user/user.d.ts
vendored
30
packages/types/src/current-user/user.d.ts
vendored
|
|
@ -1,30 +0,0 @@
|
|||
export type TCurrentUser = {
|
||||
id: string | undefined;
|
||||
avatar: string | undefined;
|
||||
cover_image: string | undefined;
|
||||
date_joined: Date | undefined;
|
||||
display_name: string | undefined;
|
||||
email: string | undefined;
|
||||
first_name: string | undefined;
|
||||
last_name: string | undefined;
|
||||
is_active: boolean;
|
||||
is_bot: boolean;
|
||||
is_email_verified: boolean;
|
||||
is_managed: boolean;
|
||||
mobile_number: string | undefined;
|
||||
user_timezone: string | undefined;
|
||||
username: string | undefined;
|
||||
is_password_autoset: boolean;
|
||||
};
|
||||
|
||||
export type TCurrentUserSettings = {
|
||||
id: string | undefined;
|
||||
email: string | undefined;
|
||||
workspace: {
|
||||
last_workspace_id: string | undefined;
|
||||
last_workspace_slug: string | undefined;
|
||||
fallback_workspace_id: string | undefined;
|
||||
fallback_workspace_slug: string | undefined;
|
||||
invites: number | undefined;
|
||||
};
|
||||
};
|
||||
2
packages/types/src/cycle/cycle.d.ts
vendored
2
packages/types/src/cycle/cycle.d.ts
vendored
|
|
@ -20,7 +20,7 @@ export type TCycleEstimateDistributionBase = {
|
|||
|
||||
export type TCycleAssigneesDistribution = {
|
||||
assignee_id: string | null;
|
||||
avatar: string | null;
|
||||
avatar_url: string | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
display_name: string | null;
|
||||
|
|
|
|||
|
|
@ -48,3 +48,14 @@ export enum ENotificationFilterType {
|
|||
ASSIGNED = "assigned",
|
||||
SUBSCRIBED = "subscribed",
|
||||
}
|
||||
|
||||
export enum EFileAssetType {
|
||||
COMMENT_DESCRIPTION = "COMMENT_DESCRIPTION",
|
||||
ISSUE_ATTACHMENT = "ISSUE_ATTACHMENT",
|
||||
ISSUE_DESCRIPTION = "ISSUE_DESCRIPTION",
|
||||
PAGE_DESCRIPTION = "PAGE_DESCRIPTION",
|
||||
PROJECT_COVER = "PROJECT_COVER",
|
||||
USER_AVATAR = "USER_AVATAR",
|
||||
USER_COVER = "USER_COVER",
|
||||
WORKSPACE_LOGO = "WORKSPACE_LOGO",
|
||||
}
|
||||
32
packages/types/src/file.d.ts
vendored
Normal file
32
packages/types/src/file.d.ts
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { EFileAssetType } from "./enums"
|
||||
|
||||
export type TFileMetaDataLite = {
|
||||
name: string;
|
||||
// file size in bytes
|
||||
size: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export type TFileEntityInfo = {
|
||||
entity_identifier: string;
|
||||
entity_type: EFileAssetType;
|
||||
}
|
||||
|
||||
export type TFileMetaData = TFileMetaDataLite & TFileEntityInfo;
|
||||
|
||||
export type TFileSignedURLResponse = {
|
||||
asset_id: string;
|
||||
asset_url: string;
|
||||
upload_data: {
|
||||
url: string;
|
||||
fields: {
|
||||
"Content-Type": string;
|
||||
key: string;
|
||||
"x-amz-algorithm": string;
|
||||
"x-amz-credential": string;
|
||||
"x-amz-date": string;
|
||||
policy: string;
|
||||
"x-amz-signature": string;
|
||||
};
|
||||
};
|
||||
};
|
||||
1
packages/types/src/index.d.ts
vendored
1
packages/types/src/index.d.ts
vendored
|
|
@ -29,4 +29,5 @@ export * from "./pragmatic";
|
|||
export * from "./publish";
|
||||
export * from "./workspace-notifications";
|
||||
export * from "./favorite";
|
||||
export * from "./file";
|
||||
export * from "./workspace-draft-issues/base";
|
||||
|
|
|
|||
1
packages/types/src/integration.d.ts
vendored
1
packages/types/src/integration.d.ts
vendored
|
|
@ -1,7 +1,6 @@
|
|||
// All the app integrations that are available
|
||||
export interface IAppIntegration {
|
||||
author: string;
|
||||
author: "";
|
||||
avatar_url: string | null;
|
||||
created_at: string;
|
||||
created_by: string | null;
|
||||
|
|
|
|||
2
packages/types/src/issues/activity/base.d.ts
vendored
2
packages/types/src/issues/activity/base.d.ts
vendored
|
|
@ -40,7 +40,7 @@ export type TIssueActivityUserDetail = {
|
|||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
avatar: string;
|
||||
avatar_url: string;
|
||||
is_bot: boolean;
|
||||
display_name: string;
|
||||
};
|
||||
|
|
|
|||
2
packages/types/src/issues/issue.d.ts
vendored
2
packages/types/src/issues/issue.d.ts
vendored
|
|
@ -45,7 +45,7 @@ export type TIssue = TBaseIssue & {
|
|||
is_subscribed?: boolean;
|
||||
parent?: Partial<TBaseIssue>;
|
||||
issue_reactions?: TIssueReaction[];
|
||||
issue_attachment?: TIssueAttachment[];
|
||||
issue_attachments?: TIssueAttachment[];
|
||||
issue_link?: TIssueLink[];
|
||||
// tempId is used for optimistic updates. It is not a part of the API response.
|
||||
tempId?: string;
|
||||
|
|
|
|||
11
packages/types/src/issues/issue_attachment.d.ts
vendored
11
packages/types/src/issues/issue_attachment.d.ts
vendored
|
|
@ -1,17 +1,22 @@
|
|||
import { TFileSignedURLResponse } from "../file";
|
||||
|
||||
export type TIssueAttachment = {
|
||||
id: string;
|
||||
attributes: {
|
||||
name: string;
|
||||
size: number;
|
||||
};
|
||||
asset: string;
|
||||
asset_url: string;
|
||||
issue_id: string;
|
||||
|
||||
//need
|
||||
// required
|
||||
updated_at: string;
|
||||
updated_by: string;
|
||||
};
|
||||
|
||||
export type TIssueAttachmentUploadResponse = TFileSignedURLResponse & {
|
||||
attachment: TIssueAttachment
|
||||
};
|
||||
|
||||
export type TIssueAttachmentMap = {
|
||||
[issue_id: string]: TIssueAttachment;
|
||||
};
|
||||
|
|
|
|||
2
packages/types/src/module/modules.d.ts
vendored
2
packages/types/src/module/modules.d.ts
vendored
|
|
@ -26,7 +26,7 @@ export type TModuleEstimateDistributionBase = {
|
|||
|
||||
export type TModuleAssigneesDistribution = {
|
||||
assignee_id: string | null;
|
||||
avatar: string | null;
|
||||
avatar_url: string | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
display_name: string | null;
|
||||
|
|
|
|||
4
packages/types/src/project/projects.d.ts
vendored
4
packages/types/src/project/projects.d.ts
vendored
|
|
@ -18,7 +18,7 @@ export interface IProject {
|
|||
close_in: number;
|
||||
created_at: Date;
|
||||
created_by: string;
|
||||
cover_image: string | null;
|
||||
cover_image_url: string;
|
||||
cycle_view: boolean;
|
||||
issue_views_view: boolean;
|
||||
module_view: boolean;
|
||||
|
|
@ -75,7 +75,7 @@ export interface IProjectMap {
|
|||
|
||||
export interface IProjectMemberLite {
|
||||
id: string;
|
||||
member__avatar: string;
|
||||
member__avatar_url: string;
|
||||
member__display_name: string;
|
||||
member_id: string;
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue