[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:
Aaryan Khandelwal 2024-10-11 20:13:38 +05:30 committed by GitHub
parent c9580ab794
commit 7e334203f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
241 changed files with 5326 additions and 2518 deletions

View file

@ -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"

View 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}`;
};

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

View file

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

View file

@ -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",

View file

@ -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

View file

@ -13,6 +13,7 @@ class UserLiteSerializer(BaseSerializer):
"last_name",
"email",
"avatar",
"avatar_url",
"display_name",
"email",
]

View file

@ -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"],

View file

@ -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)

View file

@ -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"))

View file

@ -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

View file

@ -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

View file

@ -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
]

View file

@ -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",

View file

@ -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",
]

View file

@ -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(),
),
]

View file

@ -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(),

View file

@ -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 (

View file

@ -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")
)

View 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)

View file

@ -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(

View file

@ -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",

View file

@ -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"))

View file

@ -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"))

View file

@ -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"))

View file

@ -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",

View file

@ -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)

View file

@ -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")

View file

@ -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"))

View file

@ -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"))

View file

@ -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(

View file

@ -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(

View file

@ -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"))

View file

@ -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

View file

@ -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):

View file

@ -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"))

View file

@ -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,

View file

@ -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",

View file

@ -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,
},

View file

@ -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()

View file

@ -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": {

View file

@ -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(

View 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}"))

View file

@ -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"),
),
]

View 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,
),
]

View file

@ -24,7 +24,6 @@ from .issue import (
Issue,
IssueActivity,
IssueAssignee,
IssueAttachment,
IssueBlocker,
IssueComment,
IssueLabel,

View file

@ -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

View file

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

View file

@ -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}>"

View file

@ -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)

View file

@ -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"

View file

@ -11,6 +11,7 @@ class InstanceAdminMeSerializer(BaseSerializer):
fields = [
"id",
"avatar",
"avatar_url",
"cover_image",
"date_joined",
"display_name",

View file

@ -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",
]

View 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", {}),
}

View file

@ -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",

View file

@ -13,6 +13,7 @@ class UserLiteSerializer(BaseSerializer):
"first_name",
"last_name",
"avatar",
"avatar_url",
"is_bot",
"display_name",
]

View file

@ -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,
]

View 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",
),
]

View file

@ -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")

View file

@ -23,3 +23,9 @@ from .module import ProjectModulesEndpoint
from .state import ProjectStatesEndpoint
from .label import ProjectLabelsEndpoint
from .asset import (
EntityAssetEndpoint,
AssetRestoreEndpoint,
EntityBulkAssetEndpoint,
)

View 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)

View file

@ -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"))

View file

@ -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"
),

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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

View file

@ -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}

View file

@ -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]
);

View file

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

View file

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

View file

@ -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,
];
};

View file

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

View file

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

View file

@ -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,
];
};

View file

@ -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,

View file

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

View file

@ -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,

View file

@ -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,
],

View file

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

View file

@ -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;

View file

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

View file

@ -44,5 +44,6 @@ export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & {
};
export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & {
fileHandler: Pick<TFileHandler, "getAssetSrc">;
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
};

View file

@ -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";

View file

@ -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;

View file

@ -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>;

View file

@ -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;

View file

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

View file

@ -1,3 +1 @@
export * from "./user";
export * from "./profile";
export * from "./accounts";

View file

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

View file

@ -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;

View file

@ -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
View 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;
};
};
};

View file

@ -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";

View file

@ -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;

View file

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

View file

@ -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;

View file

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

View file

@ -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;

View file

@ -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