diff --git a/apps/api/plane/api/urls/issue.py b/apps/api/plane/api/urls/issue.py index f4ca68b1ed..df012143bf 100644 --- a/apps/api/plane/api/urls/issue.py +++ b/apps/api/plane/api/urls/issue.py @@ -90,7 +90,9 @@ urlpatterns = [ ), path( "workspaces//projects//issues//issue-attachments//", - IssueAttachmentDetailAPIEndpoint.as_view(http_method_names=["get", "delete"]), + IssueAttachmentDetailAPIEndpoint.as_view( + http_method_names=["get", "patch", "delete"] + ), name="issue-attachment", ), path( diff --git a/apps/api/plane/api/views/issue.py b/apps/api/plane/api/views/issue.py index 0c213de133..96f707d37e 100644 --- a/apps/api/plane/api/views/issue.py +++ b/apps/api/plane/api/views/issue.py @@ -76,7 +76,7 @@ from .base import BaseAPIView from plane.utils.host import base_host from plane.bgtasks.webhook_task import model_activity - +from plane.app.permissions import ROLE from plane.utils.openapi import ( work_item_docs, label_docs, @@ -151,6 +151,22 @@ from plane.ee.bgtasks.entity_issue_state_progress_task import ( entity_issue_state_activity_task, ) +def user_has_issue_permission( + user_id, project_id, issue=None, allowed_roles=None, allow_creator=True +): + if allow_creator and issue is not None and user_id == issue.created_by_id: + return True + + qs = ProjectMember.objects.filter( + project_id=project_id, + member_id=user_id, + is_active=True, + ) + if allowed_roles is not None: + qs = qs.filter(role__in=allowed_roles) + + return qs.exists() + class WorkspaceIssueAPIEndpoint(BaseAPIView): """ @@ -356,6 +372,10 @@ class IssueListCreateAPIEndpoint(BaseAPIView): ) ) + total_issue_queryset = Issue.issue_objects.filter( + project_id=project_id, workspace__slug=slug + ) + # Priority Ordering if order_by_param == "priority" or order_by_param == "-priority": priority_order = ( @@ -415,6 +435,7 @@ class IssueListCreateAPIEndpoint(BaseAPIView): return self.paginate( request=request, queryset=(issue_queryset), + total_count_queryset=total_issue_queryset, on_results=lambda issues: IssueSerializer( issues, many=True, fields=self.fields, expand=self.expand ).data, @@ -1934,7 +1955,6 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView): serializer_class = IssueAttachmentSerializer model = FileAsset - permission_classes = [ProjectEntityPermission] use_read_replica = True @issue_attachment_docs( @@ -2017,6 +2037,22 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView): Generate presigned URL for uploading file attachments to a work item. Validates file type and size before creating the attachment record. """ + issue = Issue.objects.get( + pk=issue_id, workspace__slug=slug, project_id=project_id + ) + # if the user is creator or admin,member then allow the upload + if not user_has_issue_permission( + request.user.id, + project_id=project_id, + issue=issue, + allowed_roles=[ROLE.ADMIN.value, ROLE.MEMBER.value], + allow_creator=True, + ): + return Response( + {"error": "You are not allowed to upload this attachment"}, + status=status.HTTP_403_FORBIDDEN, + ) + name = request.data.get("name") type = request.data.get("type", False) size = request.data.get("size") @@ -2149,7 +2185,6 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView): """Issue Attachment Detail Endpoint""" serializer_class = IssueAttachmentSerializer - permission_classes = [ProjectEntityPermission] model = FileAsset use_read_replica = True @@ -2172,6 +2207,22 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView): Soft delete an attachment from a work item by marking it as deleted. Records deletion activity and triggers metadata cleanup. """ + issue = Issue.objects.get( + pk=issue_id, workspace__slug=slug, project_id=project_id + ) + # if the request user is creator or admin then delete the attachment + if not user_has_issue_permission( + request.user, + project_id=project_id, + issue=issue, + allowed_roles=[ROLE.ADMIN.value], + allow_creator=True, + ): + return Response( + {"error": "You are not allowed to delete this attachment"}, + status=status.HTTP_403_FORBIDDEN, + ) + issue_attachment = FileAsset.objects.get( pk=pk, workspace__slug=slug, project_id=project_id ) @@ -2234,6 +2285,19 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView): Retrieve details of a specific attachment. """ + # if the user is part of the project then allow the download + if not user_has_issue_permission( + request.user, + project_id=project_id, + issue=None, + allowed_roles=None, + allow_creator=False, + ): + return Response( + {"error": "You are not allowed to download this attachment"}, + status=status.HTTP_403_FORBIDDEN, + ) + # Get the asset asset = FileAsset.objects.get( id=pk, workspace__slug=slug, project_id=project_id @@ -2288,6 +2352,23 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView): Mark an attachment as uploaded after successful file transfer to storage. Triggers activity logging and metadata extraction. """ + + issue = Issue.objects.get( + pk=issue_id, workspace__slug=slug, project_id=project_id + ) + # if the user is creator or admin then allow the upload + if not user_has_issue_permission( + request.user, + project_id=project_id, + issue=issue, + allowed_roles=[ROLE.ADMIN.value, ROLE.MEMBER.value], + allow_creator=True, + ): + return Response( + {"error": "You are not allowed to upload this attachment"}, + status=status.HTTP_403_FORBIDDEN, + ) + issue_attachment = FileAsset.objects.get( pk=pk, workspace__slug=slug, project_id=project_id ) diff --git a/apps/api/plane/app/views/issue/archive.py b/apps/api/plane/app/views/issue/archive.py index c7aae0f6c4..4d0252eaa2 100644 --- a/apps/api/plane/app/views/issue/archive.py +++ b/apps/api/plane/app/views/issue/archive.py @@ -3,7 +3,7 @@ import json # Django imports from django.core.serializers.json import DjangoJSONEncoder -from django.db.models import F, Func, OuterRef, Q, Prefetch, Exists, Subquery +from django.db.models import F, Func, OuterRef, Q, Prefetch, Exists, Subquery, Count from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page @@ -71,25 +71,31 @@ class IssueArchiveViewSet(BaseViewSet): ) ) .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=FileAsset.objects.filter( - issue_id=OuterRef("id"), - entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + link_count=Subquery( + IssueLink.objects.filter(issue=OuterRef("id")) + .values("issue") + .annotate(count=Count("id")) + .values("count") ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") ) .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") + attachment_count=Subquery( + FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ) + .values("issue_id") + .annotate(count=Count("id")) + .values("count") + ) + ) + .annotate( + sub_issues_count=Subquery( + Issue.issue_objects.filter(parent=OuterRef("id")) + .values("parent") + .annotate(count=Count("id")) + .values("count") + ) ) ) @@ -103,6 +109,19 @@ class IssueArchiveViewSet(BaseViewSet): issue_queryset = self.get_queryset().filter(**filters) + total_issue_queryset = Issue.objects.filter( + deleted_at__isnull=True, + archived_at__isnull=False, + project_id=project_id, + workspace__slug=slug, + ).filter(**filters) + + total_issue_queryset = ( + total_issue_queryset + if show_sub_issues == "true" + else total_issue_queryset.filter(parent__isnull=True) + ) + issue_queryset = ( issue_queryset if show_sub_issues == "true" @@ -138,6 +157,7 @@ class IssueArchiveViewSet(BaseViewSet): request=request, order_by=order_by_param, queryset=issue_queryset, + total_count_queryset=total_issue_queryset, on_results=lambda issues: issue_on_results( group_by=group_by, issues=issues, sub_group_by=sub_group_by ), @@ -172,6 +192,7 @@ class IssueArchiveViewSet(BaseViewSet): request=request, order_by=order_by_param, queryset=issue_queryset, + total_count_queryset=total_issue_queryset, on_results=lambda issues: issue_on_results( group_by=group_by, issues=issues, sub_group_by=sub_group_by ), @@ -198,6 +219,7 @@ class IssueArchiveViewSet(BaseViewSet): order_by=order_by_param, request=request, queryset=issue_queryset, + total_count_queryset=total_issue_queryset, on_results=lambda issues: issue_on_results( group_by=group_by, issues=issues, sub_group_by=sub_group_by ), diff --git a/apps/api/plane/app/views/issue/base.py b/apps/api/plane/app/views/issue/base.py index 1137efd825..3b707cbbc4 100644 --- a/apps/api/plane/app/views/issue/base.py +++ b/apps/api/plane/app/views/issue/base.py @@ -273,19 +273,23 @@ class IssueViewSet(BaseViewSet): ) ) .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") + link_count=Subquery( + IssueLink.objects.filter(issue=OuterRef("id")) + .values("issue") + .annotate(count=Count("id")) + .values("count") + ) ) .annotate( - attachment_count=FileAsset.objects.filter( - issue_id=OuterRef("id"), - entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + attachment_count=Subquery( + FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ) + .values("issue_id") + .annotate(count=Count("id")) + .values("count") ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") ) .annotate( sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) @@ -356,6 +360,10 @@ class IssueViewSet(BaseViewSet): # Custom ordering for priority and state + total_issue_queryset = Issue.issue_objects.filter( + project_id=project_id, workspace__slug=slug + ).filter(**filters, **extra_filters) + # Issue queryset issue_queryset, order_by_param = order_issue_queryset( issue_queryset=issue_queryset, order_by_param=order_by_param @@ -391,6 +399,7 @@ class IssueViewSet(BaseViewSet): ) ): issue_queryset = issue_queryset.filter(created_by=request.user) + total_issue_queryset = total_issue_queryset.filter(created_by=request.user) if group_by: if sub_group_by: @@ -406,6 +415,7 @@ class IssueViewSet(BaseViewSet): request=request, order_by=order_by_param, queryset=issue_queryset, + total_count_queryset=total_issue_queryset, on_results=lambda issues: issue_on_results( group_by=group_by, issues=issues, @@ -443,6 +453,7 @@ class IssueViewSet(BaseViewSet): request=request, order_by=order_by_param, queryset=issue_queryset, + total_count_queryset=total_issue_queryset, on_results=lambda issues: issue_on_results( group_by=group_by, issues=issues, @@ -472,6 +483,7 @@ class IssueViewSet(BaseViewSet): order_by=order_by_param, request=request, queryset=issue_queryset, + total_count_queryset=total_issue_queryset, on_results=lambda issues: issue_on_results( group_by=group_by, issues=issues, diff --git a/apps/api/plane/utils/paginator.py b/apps/api/plane/utils/paginator.py index ce9c65f644..0d065e2531 100644 --- a/apps/api/plane/utils/paginator.py +++ b/apps/api/plane/utils/paginator.py @@ -160,7 +160,7 @@ class OffsetPaginator: total_count = ( self.total_count_queryset.count() if self.total_count_queryset - else results.count() + else queryset.count() ) # Check if there are more results available after the current page diff --git a/apps/web/core/components/home/root.tsx b/apps/web/core/components/home/root.tsx index 7dd2431020..24c9fe4d1f 100644 --- a/apps/web/core/components/home/root.tsx +++ b/apps/web/core/components/home/root.tsx @@ -60,11 +60,11 @@ export const WorkspaceHomeView = observer(() => { )} <> - - {currentUser && } - + +
+ {currentUser && } + +