@@ -132,7 +132,7 @@ docker compose up -d
diff --git a/apiserver/plane/api/permissions/__init__.py b/apiserver/plane/api/permissions/__init__.py
index 91b3aea35..8b15a9373 100644
--- a/apiserver/plane/api/permissions/__init__.py
+++ b/apiserver/plane/api/permissions/__init__.py
@@ -1,2 +1,2 @@
-from .workspace import WorkSpaceBasePermission, WorkSpaceAdminPermission, WorkspaceEntityPermission
+from .workspace import WorkSpaceBasePermission, WorkSpaceAdminPermission, WorkspaceEntityPermission, WorkspaceViewerPermission
from .project import ProjectBasePermission, ProjectEntityPermission, ProjectMemberPermission, ProjectLitePermission
diff --git a/apiserver/plane/api/permissions/workspace.py b/apiserver/plane/api/permissions/workspace.py
index 7fccc455e..d01b545ee 100644
--- a/apiserver/plane/api/permissions/workspace.py
+++ b/apiserver/plane/api/permissions/workspace.py
@@ -61,3 +61,13 @@ class WorkspaceEntityPermission(BasePermission):
return WorkspaceMember.objects.filter(
member=request.user, workspace__slug=view.workspace_slug
).exists()
+
+
+class WorkspaceViewerPermission(BasePermission):
+ def has_permission(self, request, view):
+ if request.user.is_anonymous:
+ return False
+
+ return WorkspaceMember.objects.filter(
+ member=request.user, workspace__slug=view.workspace_slug, role__gte=10
+ ).exists()
diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py
index 2ff210f98..2d38b1139 100644
--- a/apiserver/plane/api/serializers/__init__.py
+++ b/apiserver/plane/api/serializers/__init__.py
@@ -25,7 +25,7 @@ from .project import (
)
from .state import StateSerializer, StateLiteSerializer
from .view import IssueViewSerializer, IssueViewFavoriteSerializer
-from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer
+from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer, CycleWriteSerializer
from .asset import FileAssetSerializer
from .issue import (
IssueCreateSerializer,
@@ -43,6 +43,8 @@ from .issue import (
IssueLiteSerializer,
IssueAttachmentSerializer,
IssueSubscriberSerializer,
+ IssueReactionSerializer,
+ CommentReactionSerializer,
)
from .module import (
diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py
index 760f42dcc..5b7bb7598 100644
--- a/apiserver/plane/api/serializers/cycle.py
+++ b/apiserver/plane/api/serializers/cycle.py
@@ -12,6 +12,12 @@ from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer
from plane.db.models import Cycle, CycleIssue, CycleFavorite
+class CycleWriteSerializer(BaseSerializer):
+
+ class Meta:
+ model = Cycle
+ fields = "__all__"
+
class CycleSerializer(BaseSerializer):
owned_by = UserLiteSerializer(read_only=True)
diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py
index 7aeee7d70..ecbb1ca46 100644
--- a/apiserver/plane/api/serializers/issue.py
+++ b/apiserver/plane/api/serializers/issue.py
@@ -29,6 +29,8 @@ from plane.db.models import (
ModuleIssue,
IssueLink,
IssueAttachment,
+ IssueReaction,
+ CommentReaction,
)
@@ -50,6 +52,20 @@ class IssueFlatSerializer(BaseSerializer):
]
+class IssueProjectLiteSerializer(BaseSerializer):
+ project_detail = ProjectLiteSerializer(source="project", read_only=True)
+
+ class Meta:
+ model = Issue
+ fields = [
+ "id",
+ "project_detail",
+ "name",
+ "sequence_id",
+ ]
+ read_only_fields = fields
+
+
##TODO: Find a better way to write this serializer
## Find a better approach to save manytomany?
class IssueCreateSerializer(BaseSerializer):
@@ -101,8 +117,15 @@ class IssueCreateSerializer(BaseSerializer):
labels = validated_data.pop("labels_list", None)
blocks = validated_data.pop("blocks_list", None)
- project = self.context["project"]
- issue = Issue.objects.create(**validated_data, project=project)
+ project_id = self.context["project_id"]
+ workspace_id = self.context["workspace_id"]
+ default_assignee_id = self.context["default_assignee_id"]
+
+ issue = Issue.objects.create(**validated_data, project_id=project_id)
+
+ # Issue Audit Users
+ created_by_id = issue.created_by_id
+ updated_by_id = issue.updated_by_id
if blockers is not None and len(blockers):
IssueBlocker.objects.bulk_create(
@@ -110,10 +133,10 @@ class IssueCreateSerializer(BaseSerializer):
IssueBlocker(
block=issue,
blocked_by=blocker,
- project=project,
- workspace=project.workspace,
- created_by=issue.created_by,
- updated_by=issue.updated_by,
+ project_id=project_id,
+ workspace_id=workspace_id,
+ created_by_id=created_by_id,
+ updated_by_id=updated_by_id,
)
for blocker in blockers
],
@@ -126,10 +149,10 @@ class IssueCreateSerializer(BaseSerializer):
IssueAssignee(
assignee=user,
issue=issue,
- project=project,
- workspace=project.workspace,
- created_by=issue.created_by,
- updated_by=issue.updated_by,
+ project_id=project_id,
+ workspace_id=workspace_id,
+ created_by_id=created_by_id,
+ updated_by_id=updated_by_id,
)
for user in assignees
],
@@ -137,14 +160,14 @@ class IssueCreateSerializer(BaseSerializer):
)
else:
# Then assign it to default assignee
- if project.default_assignee is not None:
+ if default_assignee_id is not None:
IssueAssignee.objects.create(
- assignee=project.default_assignee,
+ assignee_id=default_assignee_id,
issue=issue,
- project=project,
- workspace=project.workspace,
- created_by=issue.created_by,
- updated_by=issue.updated_by,
+ project_id=project_id,
+ workspace_id=workspace_id,
+ created_by_id=created_by_id,
+ updated_by_id=updated_by_id,
)
if labels is not None and len(labels):
@@ -153,10 +176,10 @@ class IssueCreateSerializer(BaseSerializer):
IssueLabel(
label=label,
issue=issue,
- project=project,
- workspace=project.workspace,
- created_by=issue.created_by,
- updated_by=issue.updated_by,
+ project_id=project_id,
+ workspace_id=workspace_id,
+ created_by_id=created_by_id,
+ updated_by_id=updated_by_id,
)
for label in labels
],
@@ -169,10 +192,10 @@ class IssueCreateSerializer(BaseSerializer):
IssueBlocker(
block=block,
blocked_by=issue,
- project=project,
- workspace=project.workspace,
- created_by=issue.created_by,
- updated_by=issue.updated_by,
+ project_id=project_id,
+ workspace_id=workspace_id,
+ created_by_id=created_by_id,
+ updated_by_id=updated_by_id,
)
for block in blocks
],
@@ -187,6 +210,12 @@ class IssueCreateSerializer(BaseSerializer):
labels = validated_data.pop("labels_list", None)
blocks = validated_data.pop("blocks_list", None)
+ # Related models
+ project_id = instance.project_id
+ workspace_id = instance.workspace_id
+ created_by_id = instance.created_by_id
+ updated_by_id = instance.updated_by_id
+
if blockers is not None:
IssueBlocker.objects.filter(block=instance).delete()
IssueBlocker.objects.bulk_create(
@@ -194,10 +223,10 @@ class IssueCreateSerializer(BaseSerializer):
IssueBlocker(
block=instance,
blocked_by=blocker,
- project=instance.project,
- workspace=instance.project.workspace,
- created_by=instance.created_by,
- updated_by=instance.updated_by,
+ project_id=project_id,
+ workspace_id=workspace_id,
+ created_by_id=created_by_id,
+ updated_by_id=updated_by_id,
)
for blocker in blockers
],
@@ -211,10 +240,10 @@ class IssueCreateSerializer(BaseSerializer):
IssueAssignee(
assignee=user,
issue=instance,
- project=instance.project,
- workspace=instance.project.workspace,
- created_by=instance.created_by,
- updated_by=instance.updated_by,
+ project_id=project_id,
+ workspace_id=workspace_id,
+ created_by_id=created_by_id,
+ updated_by_id=updated_by_id,
)
for user in assignees
],
@@ -228,10 +257,10 @@ class IssueCreateSerializer(BaseSerializer):
IssueLabel(
label=label,
issue=instance,
- project=instance.project,
- workspace=instance.project.workspace,
- created_by=instance.created_by,
- updated_by=instance.updated_by,
+ project_id=project_id,
+ workspace_id=workspace_id,
+ created_by_id=created_by_id,
+ updated_by_id=updated_by_id,
)
for label in labels
],
@@ -245,16 +274,17 @@ class IssueCreateSerializer(BaseSerializer):
IssueBlocker(
block=block,
blocked_by=instance,
- project=instance.project,
- workspace=instance.project.workspace,
- created_by=instance.created_by,
- updated_by=instance.updated_by,
+ project_id=project_id,
+ workspace_id=workspace_id,
+ created_by_id=created_by_id,
+ updated_by_id=updated_by_id,
)
for block in blocks
],
batch_size=10,
)
+ # Time updation occues even when other related models are updated
instance.updated_at = timezone.now()
return super().update(instance, validated_data)
@@ -335,19 +365,31 @@ class IssueLabelSerializer(BaseSerializer):
class BlockedIssueSerializer(BaseSerializer):
- blocked_issue_detail = IssueFlatSerializer(source="block", read_only=True)
+ blocked_issue_detail = IssueProjectLiteSerializer(source="block", read_only=True)
class Meta:
model = IssueBlocker
- fields = "__all__"
+ fields = [
+ "blocked_issue_detail",
+ "blocked_by",
+ "block",
+ ]
+ read_only_fields = fields
class BlockerIssueSerializer(BaseSerializer):
- blocker_issue_detail = IssueFlatSerializer(source="blocked_by", read_only=True)
+ blocker_issue_detail = IssueProjectLiteSerializer(
+ source="blocked_by", read_only=True
+ )
class Meta:
model = IssueBlocker
- fields = "__all__"
+ fields = [
+ "blocker_issue_detail",
+ "blocked_by",
+ "block",
+ ]
+ read_only_fields = fields
class IssueAssigneeSerializer(BaseSerializer):
@@ -460,6 +502,89 @@ class IssueAttachmentSerializer(BaseSerializer):
]
+class IssueReactionSerializer(BaseSerializer):
+ class Meta:
+ model = IssueReaction
+ fields = "__all__"
+ read_only_fields = [
+ "workspace",
+ "project",
+ "issue",
+ "actor",
+ ]
+
+
+class IssueReactionLiteSerializer(BaseSerializer):
+ actor_detail = UserLiteSerializer(read_only=True, source="actor")
+
+ class Meta:
+ model = IssueReaction
+ fields = [
+ "id",
+ "reaction",
+ "issue",
+ "actor_detail",
+ ]
+
+
+class CommentReactionLiteSerializer(BaseSerializer):
+ actor_detail = UserLiteSerializer(read_only=True, source="actor")
+
+ class Meta:
+ model = CommentReaction
+ fields = [
+ "id",
+ "reaction",
+ "comment",
+ "actor_detail",
+ ]
+
+
+class CommentReactionSerializer(BaseSerializer):
+ class Meta:
+ model = CommentReaction
+ fields = "__all__"
+ read_only_fields = ["workspace", "project", "comment", "actor"]
+
+
+
+class IssueCommentSerializer(BaseSerializer):
+ actor_detail = UserLiteSerializer(read_only=True, source="actor")
+ issue_detail = IssueFlatSerializer(read_only=True, source="issue")
+ project_detail = ProjectLiteSerializer(read_only=True, source="project")
+ workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
+ comment_reactions = CommentReactionLiteSerializer(read_only=True, many=True)
+
+
+ class Meta:
+ model = IssueComment
+ fields = "__all__"
+ read_only_fields = [
+ "workspace",
+ "project",
+ "issue",
+ "created_by",
+ "updated_by",
+ "created_at",
+ "updated_at",
+ ]
+
+
+class IssueStateFlatSerializer(BaseSerializer):
+ state_detail = StateLiteSerializer(read_only=True, source="state")
+ project_detail = ProjectLiteSerializer(read_only=True, source="project")
+
+ class Meta:
+ model = Issue
+ fields = [
+ "id",
+ "sequence_id",
+ "name",
+ "state_detail",
+ "project_detail",
+ ]
+
+
# Issue Serializer with state details
class IssueStateSerializer(BaseSerializer):
label_details = LabelLiteSerializer(read_only=True, source="labels", many=True)
@@ -479,7 +604,7 @@ class IssueStateSerializer(BaseSerializer):
class IssueSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(read_only=True, source="project")
state_detail = StateSerializer(read_only=True, source="state")
- parent_detail = IssueFlatSerializer(read_only=True, source="parent")
+ parent_detail = IssueStateFlatSerializer(read_only=True, source="parent")
label_details = LabelSerializer(read_only=True, source="labels", many=True)
assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True)
# List of issues blocked by this issue
@@ -491,6 +616,7 @@ class IssueSerializer(BaseSerializer):
issue_link = IssueLinkSerializer(read_only=True, many=True)
issue_attachment = IssueAttachmentSerializer(read_only=True, many=True)
sub_issues_count = serializers.IntegerField(read_only=True)
+ issue_reactions = IssueReactionLiteSerializer(read_only=True, many=True)
class Meta:
model = Issue
@@ -516,6 +642,7 @@ class IssueLiteSerializer(BaseSerializer):
module_id = serializers.UUIDField(read_only=True)
attachment_count = serializers.IntegerField(read_only=True)
link_count = serializers.IntegerField(read_only=True)
+ issue_reactions = IssueReactionLiteSerializer(read_only=True, many=True)
class Meta:
model = Issue
diff --git a/apiserver/plane/api/serializers/page.py b/apiserver/plane/api/serializers/page.py
index 1eafe8966..94f7836de 100644
--- a/apiserver/plane/api/serializers/page.py
+++ b/apiserver/plane/api/serializers/page.py
@@ -3,7 +3,7 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
-from .issue import IssueFlatSerializer, LabelSerializer
+from .issue import IssueFlatSerializer, LabelLiteSerializer
from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer
from plane.db.models import Page, PageBlock, PageFavorite, PageLabel, Label
@@ -23,16 +23,22 @@ class PageBlockSerializer(BaseSerializer):
"page",
]
+class PageBlockLiteSerializer(BaseSerializer):
+
+ class Meta:
+ model = PageBlock
+ fields = "__all__"
+
class PageSerializer(BaseSerializer):
is_favorite = serializers.BooleanField(read_only=True)
- label_details = LabelSerializer(read_only=True, source="labels", many=True)
+ label_details = LabelLiteSerializer(read_only=True, source="labels", many=True)
labels_list = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()),
write_only=True,
required=False,
)
- blocks = PageBlockSerializer(read_only=True, many=True)
+ blocks = PageBlockLiteSerializer(read_only=True, many=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py
index 641edb07c..fa97c5a6d 100644
--- a/apiserver/plane/api/serializers/project.py
+++ b/apiserver/plane/api/serializers/project.py
@@ -93,6 +93,7 @@ class ProjectDetailSerializer(BaseSerializer):
total_cycles = serializers.IntegerField(read_only=True)
total_modules = serializers.IntegerField(read_only=True)
is_member = serializers.BooleanField(read_only=True)
+ sort_order = serializers.FloatField(read_only=True)
class Meta:
model = Project
diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py
index 04bbc2a47..c8b5e7b5e 100644
--- a/apiserver/plane/api/urls.py
+++ b/apiserver/plane/api/urls.py
@@ -45,6 +45,11 @@ from plane.api.views import (
UserIssueCompletedGraphEndpoint,
UserWorkspaceDashboardEndpoint,
WorkspaceThemeViewSet,
+ WorkspaceUserProfileStatsEndpoint,
+ WorkspaceUserActivityEndpoint,
+ WorkspaceUserProfileEndpoint,
+ WorkspaceUserProfileIssuesEndpoint,
+ WorkspaceLabelsEndpoint,
## End Workspaces
# File Assets
FileAssetEndpoint,
@@ -79,6 +84,8 @@ from plane.api.views import (
IssueAttachmentEndpoint,
IssueArchiveViewSet,
IssueSubscriberViewSet,
+ IssueReactionViewSet,
+ CommentReactionViewSet,
## End Issues
# States
StateViewSet,
@@ -385,6 +392,31 @@ urlpatterns = [
),
name="workspace-themes",
),
+ path(
+ "workspaces//user-stats//",
+ WorkspaceUserProfileStatsEndpoint.as_view(),
+ name="workspace-user-stats",
+ ),
+ path(
+ "workspaces//user-activity//",
+ WorkspaceUserActivityEndpoint.as_view(),
+ name="workspace-user-activity",
+ ),
+ path(
+ "workspaces//user-profile//",
+ WorkspaceUserProfileEndpoint.as_view(),
+ name="workspace-user-profile-page",
+ ),
+ path(
+ "workspaces//user-issues//",
+ WorkspaceUserProfileIssuesEndpoint.as_view(),
+ name="workspace-user-profile-issues",
+ ),
+ path(
+ "workspaces//labels/",
+ WorkspaceLabelsEndpoint.as_view(),
+ name="workspace-labels",
+ ),
## End Workspaces ##
# Projects
path(
@@ -836,6 +868,48 @@ urlpatterns = [
name="project-issue-subscribers",
),
## End Issue Subscribers
+ # Issue Reactions
+ path(
+ "workspaces//projects//issues//reactions/",
+ IssueReactionViewSet.as_view(
+ {
+ "get": "list",
+ "post": "create",
+ }
+ ),
+ name="project-issue-reactions",
+ ),
+ path(
+ "workspaces//projects//issues//reactions//",
+ IssueReactionViewSet.as_view(
+ {
+ "delete": "destroy",
+ }
+ ),
+ name="project-issue-reactions",
+ ),
+ ## End Issue Reactions
+ # Comment Reactions
+ path(
+ "workspaces//projects//comments//reactions/",
+ CommentReactionViewSet.as_view(
+ {
+ "get": "list",
+ "post": "create",
+ }
+ ),
+ name="project-issue-comment-reactions",
+ ),
+ path(
+ "workspaces//projects//comments//reactions//",
+ CommentReactionViewSet.as_view(
+ {
+ "delete": "destroy",
+ }
+ ),
+ name="project-issue-comment-reactions",
+ ),
+ ## End Comment Reactions
## IssueProperty
path(
"workspaces//projects//issue-properties/",
@@ -1240,7 +1314,7 @@ urlpatterns = [
## End Importer
# Search
path(
- "workspaces//projects//search/",
+ "workspaces//search/",
GlobalSearchEndpoint.as_view(),
name="global-search",
),
diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py
index 076cdd006..75509a16c 100644
--- a/apiserver/plane/api/views/__init__.py
+++ b/apiserver/plane/api/views/__init__.py
@@ -42,6 +42,11 @@ from .workspace import (
UserIssueCompletedGraphEndpoint,
UserWorkspaceDashboardEndpoint,
WorkspaceThemeViewSet,
+ WorkspaceUserProfileStatsEndpoint,
+ WorkspaceUserActivityEndpoint,
+ WorkspaceUserProfileEndpoint,
+ WorkspaceUserProfileIssuesEndpoint,
+ WorkspaceLabelsEndpoint,
)
from .state import StateViewSet
from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
@@ -68,6 +73,8 @@ from .issue import (
IssueAttachmentEndpoint,
IssueArchiveViewSet,
IssueSubscriberViewSet,
+ CommentReactionViewSet,
+ IssueReactionViewSet,
)
from .auth_extended import (
diff --git a/apiserver/plane/api/views/authentication.py b/apiserver/plane/api/views/authentication.py
index 0d37b1c33..aa8ff4511 100644
--- a/apiserver/plane/api/views/authentication.py
+++ b/apiserver/plane/api/views/authentication.py
@@ -279,6 +279,8 @@ class MagicSignInGenerateEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
+ # Clean up
+ email = email.strip().lower()
validate_email(email)
## Generate a random token
@@ -346,7 +348,7 @@ class MagicSignInEndpoint(BaseAPIView):
def post(self, request):
try:
user_token = request.data.get("token", "").strip()
- key = request.data.get("key", False)
+ key = request.data.get("key", False).strip().lower()
if not key or user_token == "":
return Response(
diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py
index d78333528..268485b6e 100644
--- a/apiserver/plane/api/views/cycle.py
+++ b/apiserver/plane/api/views/cycle.py
@@ -31,6 +31,7 @@ from plane.api.serializers import (
CycleIssueSerializer,
CycleFavoriteSerializer,
IssueStateSerializer,
+ CycleWriteSerializer,
)
from plane.api.permissions import ProjectEntityPermission
from plane.db.models import (
@@ -338,7 +339,7 @@ class CycleViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
- serializer = CycleSerializer(cycle, data=request.data, partial=True)
+ serializer = CycleWriteSerializer(cycle, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -691,7 +692,6 @@ class CycleDateCheckEndpoint(BaseAPIView):
return Response(
{
"error": "You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
- "cycles": CycleSerializer(cycles, many=True).data,
"status": False,
}
)
diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py
index aab926fd2..38d90ecf9 100644
--- a/apiserver/plane/api/views/issue.py
+++ b/apiserver/plane/api/views/issue.py
@@ -46,8 +46,11 @@ from plane.api.serializers import (
IssueAttachmentSerializer,
IssueSubscriberSerializer,
ProjectMemberLiteSerializer,
+ IssueReactionSerializer,
+ CommentReactionSerializer,
)
from plane.api.permissions import (
+ WorkspaceEntityPermission,
ProjectEntityPermission,
WorkSpaceAdminPermission,
ProjectMemberPermission,
@@ -65,6 +68,8 @@ from plane.db.models import (
State,
IssueSubscriber,
ProjectMember,
+ IssueReaction,
+ CommentReaction,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
@@ -151,13 +156,19 @@ class IssueViewSet(BaseViewSet):
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
+ .prefetch_related(
+ Prefetch(
+ "issue_reactions",
+ queryset=IssueReaction.objects.select_related("actor"),
+ )
+ )
)
@method_decorator(gzip_page)
def list(self, request, slug, project_id):
try:
filters = issue_filters(request.query_params, "GET")
- show_sub_issues = request.GET.get("show_sub_issues", "true")
+ print(filters)
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", None]
@@ -244,12 +255,6 @@ class IssueViewSet(BaseViewSet):
else:
issue_queryset = issue_queryset.order_by(order_by_param)
- issue_queryset = (
- issue_queryset
- if show_sub_issues == "true"
- else issue_queryset.filter(parent__isnull=True)
- )
-
issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
@@ -262,7 +267,7 @@ class IssueViewSet(BaseViewSet):
return Response(issues, status=status.HTTP_200_OK)
except Exception as e:
- print(e)
+ capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
@@ -270,9 +275,15 @@ class IssueViewSet(BaseViewSet):
def create(self, request, slug, project_id):
try:
- project = Project.objects.get(workspace__slug=slug, pk=project_id)
+ project = Project.objects.get(pk=project_id)
+
serializer = IssueCreateSerializer(
- data=request.data, context={"project": project}
+ data=request.data,
+ context={
+ "project_id": project_id,
+ "workspace_id": project.workspace_id,
+ "default_assignee_id": project.default_assignee_id,
+ },
)
if serializer.is_valid():
@@ -311,9 +322,17 @@ class UserWorkSpaceIssues(BaseAPIView):
@method_decorator(gzip_page)
def get(self, request, slug):
try:
- issues = (
+ filters = issue_filters(request.query_params, "GET")
+ # Custom ordering for priority and state
+ priority_order = ["urgent", "high", "medium", "low", None]
+ state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
+
+ order_by_param = request.GET.get("order_by", "-created_at")
+
+ issue_queryset = (
Issue.issue_objects.filter(
- assignees__in=[request.user], workspace__slug=slug
+ (Q(assignees__in=[request.user]) | Q(created_by=request.user)),
+ workspace__slug=slug,
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
@@ -327,7 +346,7 @@ class UserWorkSpaceIssues(BaseAPIView):
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
- .order_by("-created_at")
+ .order_by(order_by_param)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@@ -342,9 +361,77 @@ class UserWorkSpaceIssues(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
+ .filter(**filters)
)
- serializer = IssueLiteSerializer(issues, many=True)
- return Response(serializer.data, status=status.HTTP_200_OK)
+
+ # Priority Ordering
+ if order_by_param == "priority" or order_by_param == "-priority":
+ priority_order = (
+ priority_order
+ if order_by_param == "priority"
+ else priority_order[::-1]
+ )
+ issue_queryset = issue_queryset.annotate(
+ priority_order=Case(
+ *[
+ When(priority=p, then=Value(i))
+ for i, p in enumerate(priority_order)
+ ],
+ output_field=CharField(),
+ )
+ ).order_by("priority_order")
+
+ # State Ordering
+ elif order_by_param in [
+ "state__name",
+ "state__group",
+ "-state__name",
+ "-state__group",
+ ]:
+ state_order = (
+ state_order
+ if order_by_param in ["state__name", "state__group"]
+ else state_order[::-1]
+ )
+ issue_queryset = issue_queryset.annotate(
+ state_order=Case(
+ *[
+ When(state__group=state_group, then=Value(i))
+ for i, state_group in enumerate(state_order)
+ ],
+ default=Value(len(state_order)),
+ output_field=CharField(),
+ )
+ ).order_by("state_order")
+ # assignee and label ordering
+ elif order_by_param in [
+ "labels__name",
+ "-labels__name",
+ "assignees__first_name",
+ "-assignees__first_name",
+ ]:
+ issue_queryset = issue_queryset.annotate(
+ max_values=Max(
+ order_by_param[1::]
+ if order_by_param.startswith("-")
+ else order_by_param
+ )
+ ).order_by(
+ "-max_values" if order_by_param.startswith("-") else "max_values"
+ )
+ else:
+ issue_queryset = issue_queryset.order_by(order_by_param)
+
+ issues = IssueLiteSerializer(issue_queryset, many=True).data
+
+ ## Grouping the results
+ group_by = request.GET.get("group_by", False)
+ if group_by:
+ return Response(
+ group_results(issues, group_by), status=status.HTTP_200_OK
+ )
+
+ return Response(issues, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
@@ -396,6 +483,7 @@ class IssueActivityEndpoint(BaseAPIView):
IssueComment.objects.filter(issue_id=issue_id)
.filter(project__project_projectmember__member=self.request.user)
.order_by("created_at")
+ .select_related("actor", "issue", "project", "workspace")
)
issue_activities = IssueActivitySerializer(issue_activities, many=True).data
issue_comments = IssueCommentSerializer(issue_comments, many=True).data
@@ -418,7 +506,7 @@ class IssueCommentViewSet(BaseViewSet):
serializer_class = IssueCommentSerializer
model = IssueComment
permission_classes = [
- ProjectEntityPermission,
+ ProjectLitePermission,
]
filterset_fields = [
@@ -628,9 +716,7 @@ class SubIssuesEndpoint(BaseAPIView):
def get(self, request, slug, project_id, issue_id):
try:
sub_issues = (
- Issue.issue_objects.filter(
- parent_id=issue_id, workspace__slug=slug, project_id=project_id
- )
+ Issue.issue_objects.filter(parent_id=issue_id, workspace__slug=slug)
.select_related("project")
.select_related("workspace")
.select_related("state")
@@ -660,9 +746,7 @@ class SubIssuesEndpoint(BaseAPIView):
)
state_distribution = (
- State.objects.filter(
- ~Q(name="Triage"), workspace__slug=slug, project_id=project_id
- )
+ State.objects.filter(~Q(name="Triage"), workspace__slug=slug)
.annotate(
state_count=Count(
"state_issue",
@@ -1096,7 +1180,8 @@ class IssueArchiveViewSet(BaseViewSet):
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
except Issue.DoesNotExist:
return Response(
- {"error": "Issue Does not exist"}, status=status.HTTP_404_NOT_FOUND)
+ {"error": "Issue Does not exist"}, status=status.HTTP_404_NOT_FOUND
+ )
except Exception as e:
capture_exception(e)
return Response(
@@ -1104,6 +1189,7 @@ class IssueArchiveViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
+
class IssueSubscriberViewSet(BaseViewSet):
serializer_class = IssueSubscriberSerializer
model = IssueSubscriber
@@ -1144,18 +1230,22 @@ class IssueSubscriberViewSet(BaseViewSet):
def list(self, request, slug, project_id, issue_id):
try:
- members = ProjectMember.objects.filter(
- workspace__slug=slug, project_id=project_id
- ).annotate(
- is_subscribed=Exists(
- IssueSubscriber.objects.filter(
- workspace__slug=slug,
- project_id=project_id,
- issue_id=issue_id,
- subscriber=OuterRef("member"),
+ members = (
+ ProjectMember.objects.filter(
+ workspace__slug=slug, project_id=project_id
+ )
+ .annotate(
+ is_subscribed=Exists(
+ IssueSubscriber.objects.filter(
+ workspace__slug=slug,
+ project_id=project_id,
+ issue_id=issue_id,
+ subscriber=OuterRef("member"),
+ )
)
)
- ).select_related("member")
+ .select_related("member")
+ )
serializer = ProjectMemberLiteSerializer(members, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
@@ -1255,3 +1345,103 @@ class IssueSubscriberViewSet(BaseViewSet):
{"error": "Something went wrong, please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
+
+
+class IssueReactionViewSet(BaseViewSet):
+ serializer_class = IssueReactionSerializer
+ model = IssueReaction
+ permission_classes = [
+ ProjectLitePermission,
+ ]
+
+ def get_queryset(self):
+ return (
+ super()
+ .get_queryset()
+ .filter(workspace__slug=self.kwargs.get("slug"))
+ .filter(project_id=self.kwargs.get("project_id"))
+ .filter(issue_id=self.kwargs.get("issue_id"))
+ .filter(project__project_projectmember__member=self.request.user)
+ .order_by("-created_at")
+ .distinct()
+ )
+
+ def perform_create(self, serializer):
+ serializer.save(
+ issue_id=self.kwargs.get("issue_id"),
+ project_id=self.kwargs.get("project_id"),
+ actor=self.request.user,
+ )
+
+ def destroy(self, request, slug, project_id, issue_id, reaction_code):
+ try:
+ issue_reaction = IssueReaction.objects.get(
+ workspace__slug=slug,
+ project_id=project_id,
+ issue_id=issue_id,
+ reaction=reaction_code,
+ actor=request.user,
+ )
+ issue_reaction.delete()
+ return Response(status=status.HTTP_204_NO_CONTENT)
+ except IssueReaction.DoesNotExist:
+ return Response(
+ {"error": "Issue reaction does not exist"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ except Exception as e:
+ capture_exception(e)
+ return Response(
+ {"error": "Something went wrong please try again later"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+
+class CommentReactionViewSet(BaseViewSet):
+ serializer_class = CommentReactionSerializer
+ model = CommentReaction
+ permission_classes = [
+ ProjectLitePermission,
+ ]
+
+ def get_queryset(self):
+ return (
+ super()
+ .get_queryset()
+ .filter(workspace__slug=self.kwargs.get("slug"))
+ .filter(project_id=self.kwargs.get("project_id"))
+ .filter(comment_id=self.kwargs.get("comment_id"))
+ .filter(project__project_projectmember__member=self.request.user)
+ .order_by("-created_at")
+ .distinct()
+ )
+
+ def perform_create(self, serializer):
+ serializer.save(
+ actor=self.request.user,
+ comment_id=self.kwargs.get("comment_id"),
+ project_id=self.kwargs.get("project_id"),
+ )
+
+ def destroy(self, request, slug, project_id, comment_id, reaction_code):
+ try:
+ comment_reaction = CommentReaction.objects.get(
+ workspace__slug=slug,
+ project_id=project_id,
+ comment_id=comment_id,
+ reaction=reaction_code,
+ actor=request.user,
+ )
+ comment_reaction.delete()
+ return Response(status=status.HTTP_204_NO_CONTENT)
+ except CommentReaction.DoesNotExist:
+ return Response(
+ {"error": "Comment reaction does not exist"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ except Exception as e:
+ capture_exception(e)
+ return Response(
+ {"error": "Something went wrong please try again later"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
diff --git a/apiserver/plane/api/views/notification.py b/apiserver/plane/api/views/notification.py
index b7f5bd335..2abc82631 100644
--- a/apiserver/plane/api/views/notification.py
+++ b/apiserver/plane/api/views/notification.py
@@ -6,14 +6,15 @@ from django.utils import timezone
from rest_framework import status
from rest_framework.response import Response
from sentry_sdk import capture_exception
+from plane.utils.paginator import BasePaginator
# Module imports
from .base import BaseViewSet, BaseAPIView
-from plane.db.models import Notification, IssueAssignee, IssueSubscriber, Issue
+from plane.db.models import Notification, IssueAssignee, IssueSubscriber, Issue, WorkspaceMember
from plane.api.serializers import NotificationSerializer
-class NotificationViewSet(BaseViewSet):
+class NotificationViewSet(BaseViewSet, BasePaginator):
model = Notification
serializer_class = NotificationSerializer
@@ -37,9 +38,13 @@ class NotificationViewSet(BaseViewSet):
# Filter type
type = request.GET.get("type", "all")
- notifications = Notification.objects.filter(
- workspace__slug=slug, receiver_id=request.user.id
- ).order_by("snoozed_till", "-created_at")
+ notifications = (
+ Notification.objects.filter(
+ workspace__slug=slug, receiver_id=request.user.id
+ )
+ .select_related("workspace", "project", "triggered_by", "receiver")
+ .order_by("snoozed_till", "-created_at")
+ )
# Filter for snoozed notifications
if snoozed == "false":
@@ -78,10 +83,23 @@ class NotificationViewSet(BaseViewSet):
# Created issues
if type == "created":
- issue_ids = Issue.objects.filter(
- workspace__slug=slug, created_by=request.user
- ).values_list("pk", flat=True)
- notifications = notifications.filter(entity_identifier__in=issue_ids)
+ if WorkspaceMember.objects.filter(workspace__slug=slug, member=request.user, role__lt=15).exists():
+ notifications = Notification.objects.none()
+ else:
+ issue_ids = Issue.objects.filter(
+ workspace__slug=slug, created_by=request.user
+ ).values_list("pk", flat=True)
+ notifications = notifications.filter(entity_identifier__in=issue_ids)
+
+ # Pagination
+ if request.GET.get("per_page", False) and request.GET.get("cursor", False):
+ return self.paginate(
+ request=request,
+ queryset=(notifications),
+ on_results=lambda notifications: NotificationSerializer(
+ notifications, many=True
+ ).data,
+ )
serializer = NotificationSerializer(notifications, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py
index 5c6ea3fd1..dfeab07cc 100644
--- a/apiserver/plane/api/views/project.py
+++ b/apiserver/plane/api/views/project.py
@@ -5,7 +5,7 @@ from datetime import datetime
# Django imports
from django.core.exceptions import ValidationError
from django.db import IntegrityError
-from django.db.models import Q, Exists, OuterRef, Func, F
+from django.db.models import Q, Exists, OuterRef, Func, F, Min, Subquery
from django.core.validators import validate_email
from django.conf import settings
@@ -91,6 +91,24 @@ class ProjectViewSet(BaseViewSet):
)
)
)
+ .annotate(
+ total_members=ProjectMember.objects.filter(project_id=OuterRef("id"))
+ .order_by()
+ .annotate(count=Func(F("id"), function="Count"))
+ .values("count")
+ )
+ .annotate(
+ total_cycles=Cycle.objects.filter(project_id=OuterRef("id"))
+ .order_by()
+ .annotate(count=Func(F("id"), function="Count"))
+ .values("count")
+ )
+ .annotate(
+ total_modules=Module.objects.filter(project_id=OuterRef("id"))
+ .order_by()
+ .annotate(count=Func(F("id"), function="Count"))
+ .values("count")
+ )
.distinct()
)
@@ -102,10 +120,16 @@ class ProjectViewSet(BaseViewSet):
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
)
+ sort_order_query = ProjectMember.objects.filter(
+ member=request.user,
+ project_id=OuterRef("pk"),
+ workspace__slug=self.kwargs.get("slug"),
+ ).values("sort_order")
projects = (
self.get_queryset()
.annotate(is_favorite=Exists(subquery))
- .order_by("-is_favorite", "name")
+ .annotate(sort_order=Subquery(sort_order_query))
+ .order_by("sort_order", "name")
.annotate(
total_members=ProjectMember.objects.filter(
project_id=OuterRef("id")
@@ -152,10 +176,17 @@ class ProjectViewSet(BaseViewSet):
serializer.save()
# Add the user as Administrator to the project
- ProjectMember.objects.create(
+ project_member = ProjectMember.objects.create(
project_id=serializer.data["id"], member=request.user, role=20
)
+ if serializer.data["project_lead"] is not None:
+ ProjectMember.objects.create(
+ project_id=serializer.data["id"],
+ member_id=serializer.data["project_lead"],
+ role=20,
+ )
+
# Default states
states = [
{
@@ -207,9 +238,11 @@ class ProjectViewSet(BaseViewSet):
]
)
- return Response(serializer.data, status=status.HTTP_201_CREATED)
+ data = serializer.data
+ data["sort_order"] = project_member.sort_order
+ return Response(data, status=status.HTTP_201_CREATED)
return Response(
- [serializer.errors[error][0] for error in serializer.errors],
+ serializer.errors,
status=status.HTTP_400_BAD_REQUEST,
)
except IntegrityError as e:
@@ -234,7 +267,7 @@ class ProjectViewSet(BaseViewSet):
status=status.HTTP_410_GONE,
)
except Exception as e:
- capture_exception(e)
+ pr(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
@@ -567,17 +600,26 @@ class AddMemberToProjectEndpoint(BaseAPIView):
{"error": "Atleast one member is required"},
status=status.HTTP_400_BAD_REQUEST,
)
+ bulk_project_members = []
- project_members = ProjectMember.objects.bulk_create(
- [
+ project_members = ProjectMember.objects.filter(
+ workspace=self.workspace, member_id__in=[member.get("member_id") for member in members]
+ ).values("member_id").annotate(sort_order_min=Min("sort_order"))
+
+ for member in members:
+ sort_order = [project_member.get("sort_order") for project_member in project_members]
+ bulk_project_members.append(
ProjectMember(
member_id=member.get("member_id"),
role=member.get("role", 10),
project_id=project_id,
workspace_id=project.workspace_id,
+ sort_order=sort_order[0] - 10000 if len(sort_order) else 65535
)
- for member in members
- ],
+ )
+
+ project_members = ProjectMember.objects.bulk_create(
+ bulk_project_members,
batch_size=10,
ignore_conflicts=True,
)
@@ -819,11 +861,15 @@ class ProjectUserViewsEndpoint(BaseAPIView):
view_props = project_member.view_props
default_props = project_member.default_props
+ preferences = project_member.preferences
+ sort_order = project_member.sort_order
project_member.view_props = request.data.get("view_props", view_props)
project_member.default_props = request.data.get(
"default_props", default_props
)
+ project_member.preferences = request.data.get("preferences", preferences)
+ project_member.sort_order = request.data.get("sort_order", sort_order)
project_member.save()
diff --git a/apiserver/plane/api/views/search.py b/apiserver/plane/api/views/search.py
index 51925dd7b..0a8c5c530 100644
--- a/apiserver/plane/api/views/search.py
+++ b/apiserver/plane/api/views/search.py
@@ -20,7 +20,7 @@ class GlobalSearchEndpoint(BaseAPIView):
also show related workspace if found
"""
- def filter_workspaces(self, query, slug, project_id):
+ def filter_workspaces(self, query, slug, project_id, workspace_search):
fields = ["name"]
q = Q()
for field in fields:
@@ -31,8 +31,8 @@ class GlobalSearchEndpoint(BaseAPIView):
.values("name", "id", "slug")
)
- def filter_projects(self, query, slug, project_id):
- fields = ["name"]
+ def filter_projects(self, query, slug, project_id, workspace_search):
+ fields = ["name", "identifier"]
q = Q()
for field in fields:
q |= Q(**{f"{field}__icontains": query})
@@ -46,8 +46,8 @@ class GlobalSearchEndpoint(BaseAPIView):
.values("name", "id", "identifier", "workspace__slug")
)
- def filter_issues(self, query, slug, project_id):
- fields = ["name", "sequence_id"]
+ def filter_issues(self, query, slug, project_id, workspace_search):
+ fields = ["name", "sequence_id", "project__identifier"]
q = Q()
for field in fields:
if field == "sequence_id":
@@ -56,111 +56,123 @@ class GlobalSearchEndpoint(BaseAPIView):
q |= Q(**{"sequence_id": sequence_id})
else:
q |= Q(**{f"{field}__icontains": query})
- return (
- Issue.issue_objects.filter(
- q,
- project__project_projectmember__member=self.request.user,
- workspace__slug=slug,
- project_id=project_id,
- )
- .distinct()
- .values(
- "name",
- "id",
- "sequence_id",
- "project__identifier",
- "project_id",
- "workspace__slug",
- )
+
+ issues = Issue.issue_objects.filter(
+ q,
+ project__project_projectmember__member=self.request.user,
+ workspace__slug=slug,
)
- def filter_cycles(self, query, slug, project_id):
+ if workspace_search == "false" and project_id:
+ issues = issues.filter(project_id=project_id)
+
+ return issues.distinct().values(
+ "name",
+ "id",
+ "sequence_id",
+ "project__identifier",
+ "project_id",
+ "workspace__slug",
+ )
+
+ def filter_cycles(self, query, slug, project_id, workspace_search):
fields = ["name"]
q = Q()
for field in fields:
q |= Q(**{f"{field}__icontains": query})
- return (
- Cycle.objects.filter(
- q,
- project__project_projectmember__member=self.request.user,
- workspace__slug=slug,
- project_id=project_id,
- )
- .distinct()
- .values(
- "name",
- "id",
- "project_id",
- "workspace__slug",
- )
+
+ cycles = Cycle.objects.filter(
+ q,
+ project__project_projectmember__member=self.request.user,
+ workspace__slug=slug,
)
- def filter_modules(self, query, slug, project_id):
+ if workspace_search == "false" and project_id:
+ cycles = cycles.filter(project_id=project_id)
+
+ return cycles.distinct().values(
+ "name",
+ "id",
+ "project_id",
+ "project__identifier",
+ "workspace__slug",
+ )
+
+ def filter_modules(self, query, slug, project_id, workspace_search):
fields = ["name"]
q = Q()
for field in fields:
q |= Q(**{f"{field}__icontains": query})
- return (
- Module.objects.filter(
- q,
- project__project_projectmember__member=self.request.user,
- workspace__slug=slug,
- project_id=project_id,
- )
- .distinct()
- .values(
- "name",
- "id",
- "project_id",
- "workspace__slug",
- )
+
+ modules = Module.objects.filter(
+ q,
+ project__project_projectmember__member=self.request.user,
+ workspace__slug=slug,
)
- def filter_pages(self, query, slug, project_id):
+ if workspace_search == "false" and project_id:
+ modules = modules.filter(project_id=project_id)
+
+ return modules.distinct().values(
+ "name",
+ "id",
+ "project_id",
+ "project__identifier",
+ "workspace__slug",
+ )
+
+ def filter_pages(self, query, slug, project_id, workspace_search):
fields = ["name"]
q = Q()
for field in fields:
q |= Q(**{f"{field}__icontains": query})
- return (
- Page.objects.filter(
- q,
- project__project_projectmember__member=self.request.user,
- workspace__slug=slug,
- project_id=project_id,
- )
- .distinct()
- .values(
- "name",
- "id",
- "project_id",
- "workspace__slug",
- )
+
+ pages = Page.objects.filter(
+ q,
+ project__project_projectmember__member=self.request.user,
+ workspace__slug=slug,
)
- def filter_views(self, query, slug, project_id):
+ if workspace_search == "false" and project_id:
+ pages = pages.filter(project_id=project_id)
+
+ return pages.distinct().values(
+ "name",
+ "id",
+ "project_id",
+ "project__identifier",
+ "workspace__slug",
+ )
+
+ def filter_views(self, query, slug, project_id, workspace_search):
fields = ["name"]
q = Q()
for field in fields:
q |= Q(**{f"{field}__icontains": query})
- return (
- IssueView.objects.filter(
- q,
- project__project_projectmember__member=self.request.user,
- workspace__slug=slug,
- project_id=project_id,
- )
- .distinct()
- .values(
- "name",
- "id",
- "project_id",
- "workspace__slug",
- )
+
+ issue_views = IssueView.objects.filter(
+ q,
+ project__project_projectmember__member=self.request.user,
+ workspace__slug=slug,
)
- def get(self, request, slug, project_id):
+ if workspace_search == "false" and project_id:
+ issue_views = issue_views.filter(project_id=project_id)
+
+ return issue_views.distinct().values(
+ "name",
+ "id",
+ "project_id",
+ "project__identifier",
+ "workspace__slug",
+ )
+
+ def get(self, request, slug):
try:
query = request.query_params.get("search", False)
+ workspace_search = request.query_params.get("workspace_search", "false")
+ project_id = request.query_params.get("project_id", False)
+
if not query:
return Response(
{
@@ -191,7 +203,7 @@ class GlobalSearchEndpoint(BaseAPIView):
for model in MODELS_MAPPER.keys():
func = MODELS_MAPPER.get(model, None)
- results[model] = func(query, slug, project_id)
+ results[model] = func(query, slug, project_id, workspace_search)
return Response({"results": results}, status=status.HTTP_200_OK)
except Exception as e:
@@ -206,6 +218,7 @@ class IssueSearchEndpoint(BaseAPIView):
def get(self, request, slug, project_id):
try:
query = request.query_params.get("search", False)
+ workspace_search = request.query_params.get("workspace_search", "false")
parent = request.query_params.get("parent", "false")
blocker_blocked_by = request.query_params.get("blocker_blocked_by", "false")
cycle = request.query_params.get("cycle", "false")
@@ -216,10 +229,12 @@ class IssueSearchEndpoint(BaseAPIView):
issues = Issue.issue_objects.filter(
workspace__slug=slug,
- project_id=project_id,
project__project_projectmember__member=self.request.user,
)
+ if workspace_search == "false":
+ issues = issues.filter(project_id=project_id)
+
if query:
issues = search_issues(query, issues)
@@ -251,12 +266,12 @@ class IssueSearchEndpoint(BaseAPIView):
if module == "true":
issues = issues.exclude(issue_module__isnull=False)
-
return Response(
issues.values(
"name",
"id",
"sequence_id",
+ "project__name",
"project__identifier",
"project_id",
"workspace__slug",
diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py
index 305deb525..51db47c3d 100644
--- a/apiserver/plane/api/views/workspace.py
+++ b/apiserver/plane/api/views/workspace.py
@@ -13,12 +13,18 @@ from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.contrib.sites.shortcuts import get_current_site
from django.db.models import (
- CharField,
- Count,
+ Prefetch,
OuterRef,
Func,
F,
Q,
+ Count,
+ Case,
+ Value,
+ CharField,
+ When,
+ Max,
+ IntegerField,
)
from django.db.models.functions import ExtractWeek, Cast, ExtractDay
from django.db.models.fields import DateField
@@ -39,6 +45,8 @@ from plane.api.serializers import (
UserLiteSerializer,
ProjectMemberSerializer,
WorkspaceThemeSerializer,
+ IssueActivitySerializer,
+ IssueLiteSerializer,
)
from plane.api.views.base import BaseAPIView
from . import BaseViewSet
@@ -60,9 +68,23 @@ from plane.db.models import (
PageFavorite,
Page,
IssueViewFavorite,
+ IssueLink,
+ IssueAttachment,
+ IssueSubscriber,
+ Project,
+ Label,
+ WorkspaceMember,
+ CycleIssue,
+)
+from plane.api.permissions import (
+ WorkSpaceBasePermission,
+ WorkSpaceAdminPermission,
+ WorkspaceEntityPermission,
+ WorkspaceViewerPermission,
)
-from plane.api.permissions import WorkSpaceBasePermission, WorkSpaceAdminPermission
from plane.bgtasks.workspace_invitation_task import workspace_invitation
+from plane.utils.issue_filters import issue_filters
+from plane.utils.grouper import group_results
class WorkSpaceViewSet(BaseViewSet):
@@ -597,6 +619,19 @@ class WorkSpaceMemberViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
+ # Check for the only member in the workspace
+ if (
+ workspace_member.role == 20
+ and WorkspaceMember.objects.filter(
+ workspace__slug=slug, role=20
+ ).count()
+ == 1
+ ):
+ return Response(
+ {"error": "Cannot delete the only Admin for the workspace"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
# Delete the user also from all the projects
ProjectMember.objects.filter(
workspace__slug=slug, member=workspace_member.member
@@ -1009,3 +1044,391 @@ class WorkspaceThemeViewSet(BaseViewSet):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
+
+
+class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
+
+ def get(self, request, slug, user_id):
+ try:
+ filters = issue_filters(request.query_params, "GET")
+
+ state_distribution = (
+ Issue.issue_objects.filter(
+ workspace__slug=slug,
+ assignees__in=[user_id],
+ project__project_projectmember__member=request.user,
+ )
+ .filter(**filters)
+ .annotate(state_group=F("state__group"))
+ .values("state_group")
+ .annotate(state_count=Count("state_group"))
+ .order_by("state_group")
+ )
+
+ priority_order = ["urgent", "high", "medium", "low", None]
+
+ priority_distribution = (
+ Issue.objects.filter(
+ workspace__slug=slug,
+ assignees__in=[user_id],
+ project__project_projectmember__member=request.user,
+ )
+ .filter(**filters)
+ .values("priority")
+ .annotate(priority_count=Count("priority"))
+ .annotate(
+ priority_order=Case(
+ *[
+ When(priority=p, then=Value(i))
+ for i, p in enumerate(priority_order)
+ ],
+ default=Value(len(priority_order)),
+ output_field=IntegerField(),
+ )
+ )
+ .order_by("priority_order")
+ )
+
+ created_issues = (
+ Issue.issue_objects.filter(
+ workspace__slug=slug,
+ assignees__in=[user_id],
+ project__project_projectmember__member=request.user,
+ created_by_id=user_id,
+ )
+ .filter(**filters)
+ .count()
+ )
+
+ assigned_issues_count = (
+ Issue.issue_objects.filter(
+ workspace__slug=slug,
+ assignees__in=[user_id],
+ project__project_projectmember__member=request.user,
+ )
+ .filter(**filters)
+ .count()
+ )
+
+ pending_issues_count = (
+ Issue.issue_objects.filter(
+ ~Q(state__group__in=["completed", "cancelled"]),
+ workspace__slug=slug,
+ assignees__in=[user_id],
+ project__project_projectmember__member=request.user,
+ )
+ .filter(**filters)
+ .count()
+ )
+
+ completed_issues_count = (
+ Issue.issue_objects.filter(
+ workspace__slug=slug,
+ assignees__in=[user_id],
+ state__group="completed",
+ project__project_projectmember__member=request.user,
+ )
+ .filter(**filters)
+ .count()
+ )
+
+ subscribed_issues_count = (
+ IssueSubscriber.objects.filter(
+ workspace__slug=slug,
+ subscriber_id=user_id,
+ project__project_projectmember__member=request.user,
+ )
+ .filter(**filters)
+ .count()
+ )
+
+ upcoming_cycles = CycleIssue.objects.filter(
+ workspace__slug=slug,
+ cycle__start_date__gt=timezone.now().date(),
+ issue__assignees__in=[user_id,]
+ ).values("cycle__name", "cycle__id", "cycle__project_id")
+
+ present_cycle = CycleIssue.objects.filter(
+ workspace__slug=slug,
+ cycle__start_date__lt=timezone.now().date(),
+ cycle__end_date__gt=timezone.now().date(),
+ issue__assignees__in=[user_id,]
+ ).values("cycle__name", "cycle__id", "cycle__project_id")
+
+ return Response(
+ {
+ "state_distribution": state_distribution,
+ "priority_distribution": priority_distribution,
+ "created_issues": created_issues,
+ "assigned_issues": assigned_issues_count,
+ "completed_issues": completed_issues_count,
+ "pending_issues": pending_issues_count,
+ "subscribed_issues": subscribed_issues_count,
+ "present_cycles": present_cycle,
+ "upcoming_cycles": upcoming_cycles,
+ }
+ )
+ except Exception as e:
+ capture_exception(e)
+ return Response(
+ {"error": "Something went wrong please try again later"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+
+class WorkspaceUserActivityEndpoint(BaseAPIView):
+ permission_classes = [
+ WorkspaceEntityPermission,
+ ]
+
+ def get(self, request, slug, user_id):
+ try:
+
+ projects = request.query_params.getlist("project", [])
+
+ queryset = IssueActivity.objects.filter(
+ workspace__slug=slug,
+ project__project_projectmember__member=request.user,
+ actor=user_id,
+ ).select_related("actor", "workspace")
+
+ if projects:
+ queryset = queryset.filter(project__in=projects)
+
+ return self.paginate(
+ request=request,
+ queryset=queryset,
+ on_results=lambda issue_activities: IssueActivitySerializer(
+ issue_activities, many=True
+ ).data,
+ )
+ except Exception as e:
+ capture_exception(e)
+ return Response(
+ {"error": "Something went wrong please try again later"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+
+class WorkspaceUserProfileEndpoint(BaseAPIView):
+
+ def get(self, request, slug, user_id):
+ try:
+ user_data = User.objects.get(pk=user_id)
+
+ requesting_workspace_member = WorkspaceMember.objects.get(workspace__slug=slug, member=request.user)
+ projects = []
+ if requesting_workspace_member.role >= 10:
+ projects = (
+ Project.objects.filter(
+ workspace__slug=slug,
+ project_projectmember__member=request.user,
+ )
+ .annotate(
+ created_issues=Count(
+ "project_issue", filter=Q(project_issue__created_by_id=user_id)
+ )
+ )
+ .annotate(
+ assigned_issues=Count(
+ "project_issue",
+ filter=Q(project_issue__assignees__in=[user_id]),
+ )
+ )
+ .annotate(
+ completed_issues=Count(
+ "project_issue",
+ filter=Q(
+ project_issue__completed_at__isnull=False,
+ project_issue__assignees__in=[user_id],
+ ),
+ )
+ )
+ .annotate(
+ pending_issues=Count(
+ "project_issue",
+ filter=Q(
+ project_issue__state__group__in=[
+ "backlog",
+ "unstarted",
+ "started",
+ ],
+ project_issue__assignees__in=[user_id],
+ ),
+ )
+ )
+ .values(
+ "id",
+ "name",
+ "identifier",
+ "emoji",
+ "icon_prop",
+ "created_issues",
+ "assigned_issues",
+ "completed_issues",
+ "pending_issues",
+ )
+ )
+
+ return Response(
+ {
+ "project_data": projects,
+ "user_data": {
+ "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,
+ "date_joined": user_data.date_joined,
+ "user_timezone": user_data.user_timezone,
+ },
+ },
+ status=status.HTTP_200_OK,
+ )
+ except WorkspaceMember.DoesNotExist:
+ return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN)
+ except Exception as e:
+ capture_exception(e)
+ return Response(
+ {"error": "Something went wrong please try again later"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+
+class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
+ permission_classes = [
+ WorkspaceViewerPermission,
+ ]
+
+ def get(self, request, slug, user_id):
+ try:
+ filters = issue_filters(request.query_params, "GET")
+ order_by_param = request.GET.get("order_by", "-created_at")
+ issue_queryset = (
+ Issue.issue_objects.filter(
+ Q(assignees__in=[user_id])
+ | Q(created_by_id=user_id)
+ | Q(issue_subscribers__subscriber_id=user_id),
+ workspace__slug=slug,
+ project__project_projectmember__member=request.user,
+ )
+ .filter(**filters)
+ .annotate(
+ sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
+ .order_by()
+ .annotate(count=Func(F("id"), function="Count"))
+ .values("count")
+ )
+ .select_related("project", "workspace", "state", "parent")
+ .prefetch_related("assignees", "labels")
+ .order_by("-created_at")
+ .annotate(
+ link_count=IssueLink.objects.filter(issue=OuterRef("id"))
+ .order_by()
+ .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")
+ )
+ ).distinct()
+
+ # Priority Ordering
+ if order_by_param == "priority" or order_by_param == "-priority":
+ priority_order = (
+ priority_order
+ if order_by_param == "priority"
+ else priority_order[::-1]
+ )
+ issue_queryset = issue_queryset.annotate(
+ priority_order=Case(
+ *[
+ When(priority=p, then=Value(i))
+ for i, p in enumerate(priority_order)
+ ],
+ output_field=CharField(),
+ )
+ ).order_by("priority_order")
+
+ # State Ordering
+ elif order_by_param in [
+ "state__name",
+ "state__group",
+ "-state__name",
+ "-state__group",
+ ]:
+ state_order = (
+ state_order
+ if order_by_param in ["state__name", "state__group"]
+ else state_order[::-1]
+ )
+ issue_queryset = issue_queryset.annotate(
+ state_order=Case(
+ *[
+ When(state__group=state_group, then=Value(i))
+ for i, state_group in enumerate(state_order)
+ ],
+ default=Value(len(state_order)),
+ output_field=CharField(),
+ )
+ ).order_by("state_order")
+ # assignee and label ordering
+ elif order_by_param in [
+ "labels__name",
+ "-labels__name",
+ "assignees__first_name",
+ "-assignees__first_name",
+ ]:
+ issue_queryset = issue_queryset.annotate(
+ max_values=Max(
+ order_by_param[1::]
+ if order_by_param.startswith("-")
+ else order_by_param
+ )
+ ).order_by(
+ "-max_values" if order_by_param.startswith("-") else "max_values"
+ )
+ else:
+ issue_queryset = issue_queryset.order_by(order_by_param)
+
+ issues = IssueLiteSerializer(issue_queryset, many=True).data
+
+ ## Grouping the results
+ group_by = request.GET.get("group_by", False)
+ if group_by:
+ return Response(
+ group_results(issues, group_by), status=status.HTTP_200_OK
+ )
+
+ return Response(issues, status=status.HTTP_200_OK)
+ except Exception as e:
+ capture_exception(e)
+ return Response(
+ {"error": "Something went wrong please try again later"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+
+class WorkspaceLabelsEndpoint(BaseAPIView):
+ permission_classes = [
+ WorkspaceViewerPermission,
+ ]
+
+ def get(self, request, slug):
+ try:
+ labels = Label.objects.filter(
+ workspace__slug=slug,
+ project__project_projectmember__member=request.user,
+ ).values("parent", "name", "color", "id", "project_id", "workspace__slug")
+ return Response(labels, status=status.HTTP_200_OK)
+ except Exception as e:
+ capture_exception(e)
+ return Response(
+ {"error": "Something went wrong please try again later"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py
index 105a05b56..8f34daf52 100644
--- a/apiserver/plane/bgtasks/issue_activites_task.py
+++ b/apiserver/plane/bgtasks/issue_activites_task.py
@@ -70,7 +70,7 @@ def track_parent(
issue_id=issue_id,
actor=actor,
verb="updated",
- old_value=f"{project.identifier}-{old_parent.sequence_id}",
+ old_value=f"{old_parent.project.identifier}-{old_parent.sequence_id}",
new_value=None,
field="parent",
project=project,
@@ -88,10 +88,10 @@ def track_parent(
issue_id=issue_id,
actor=actor,
verb="updated",
- old_value=f"{project.identifier}-{old_parent.sequence_id}"
+ old_value=f"{old_parent.project.identifier}-{old_parent.sequence_id}"
if old_parent is not None
else None,
- new_value=f"{project.identifier}-{new_parent.sequence_id}",
+ new_value=f"{new_parent.project.identifier}-{new_parent.sequence_id}",
field="parent",
project=project,
workspace=project.workspace,
@@ -376,7 +376,7 @@ def track_assignees(
verb="updated",
old_value=assignee.email,
new_value="",
- field="assignee",
+ field="assignees",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed assignee {assignee.email}",
@@ -415,11 +415,11 @@ def track_blocks(
actor=actor,
verb="updated",
old_value="",
- new_value=f"{project.identifier}-{issue.sequence_id}",
+ new_value=f"{issue.project.identifier}-{issue.sequence_id}",
field="blocks",
project=project,
workspace=project.workspace,
- comment=f"{actor.email} added blocking issue {project.identifier}-{issue.sequence_id}",
+ comment=f"{actor.email} added blocking issue {issue.project.identifier}-{issue.sequence_id}",
new_identifier=issue.id,
)
)
@@ -436,12 +436,12 @@ def track_blocks(
issue_id=issue_id,
actor=actor,
verb="updated",
- old_value=f"{project.identifier}-{issue.sequence_id}",
+ old_value=f"{issue.project.identifier}-{issue.sequence_id}",
new_value="",
field="blocks",
project=project,
workspace=project.workspace,
- comment=f"{actor.email} removed blocking issue {project.identifier}-{issue.sequence_id}",
+ comment=f"{actor.email} removed blocking issue {issue.project.identifier}-{issue.sequence_id}",
old_identifier=issue.id,
)
)
@@ -477,11 +477,11 @@ def track_blockings(
actor=actor,
verb="updated",
old_value="",
- new_value=f"{project.identifier}-{issue.sequence_id}",
+ new_value=f"{issue.project.identifier}-{issue.sequence_id}",
field="blocking",
project=project,
workspace=project.workspace,
- comment=f"{actor.email} added blocked by issue {project.identifier}-{issue.sequence_id}",
+ comment=f"{actor.email} added blocked by issue {issue.project.identifier}-{issue.sequence_id}",
new_identifier=issue.id,
)
)
@@ -498,12 +498,12 @@ def track_blockings(
issue_id=issue_id,
actor=actor,
verb="updated",
- old_value=f"{project.identifier}-{issue.sequence_id}",
+ old_value=f"{issue.project.identifier}-{issue.sequence_id}",
new_value="",
field="blocking",
project=project,
workspace=project.workspace,
- comment=f"{actor.email} removed blocked by issue {project.identifier}-{issue.sequence_id}",
+ comment=f"{actor.email} removed blocked by issue {issue.project.identifier}-{issue.sequence_id}",
old_identifier=issue.id,
)
)
@@ -959,6 +959,11 @@ def update_link_activity(
def delete_link_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
+
+ current_instance = (
+ json.loads(current_instance) if current_instance is not None else None
+ )
+
issue_activities.append(
IssueActivity(
issue_id=issue_id,
@@ -968,6 +973,8 @@ def delete_link_activity(
verb="deleted",
actor=actor,
field="link",
+ old_value=current_instance.get("url", ""),
+ new_value=""
)
)
@@ -989,7 +996,7 @@ def create_attachment_activity(
verb="created",
actor=actor,
field="attachment",
- new_value=current_instance.get("access", ""),
+ new_value=current_instance.get("asset", ""),
new_identifier=current_instance.get("id", None),
)
)
@@ -1034,11 +1041,14 @@ def issue_activity(
"module.activity.created",
"module.activity.deleted",
]:
- issue = Issue.objects.filter(pk=issue_id, project_id=project_id).first()
+ issue = Issue.objects.filter(pk=issue_id).first()
if issue is not None:
- issue.updated_at = timezone.now()
- issue.save(update_fields=["updated_at"])
+ try:
+ issue.updated_at = timezone.now()
+ issue.save(update_fields=["updated_at"])
+ except Exception as e:
+ pass
if subscriber:
# add the user to issue subscriber
@@ -1122,7 +1132,7 @@ def issue_activity(
issue_subscribers = issue_subscribers + issue_assignees
- issue = Issue.objects.filter(pk=issue_id, project_id=project_id).first()
+ issue = Issue.objects.filter(pk=issue_id).first()
# Add bot filtering
if (
@@ -1149,7 +1159,7 @@ def issue_activity(
"issue": {
"id": str(issue_id),
"name": str(issue.name),
- "identifier": str(project.identifier),
+ "identifier": str(issue.project.identifier),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name,
"state_group": issue.state.group,
diff --git a/apiserver/plane/db/migrations/0039_auto_20230723_2203.py b/apiserver/plane/db/migrations/0039_auto_20230723_2203.py
new file mode 100644
index 000000000..5d5747543
--- /dev/null
+++ b/apiserver/plane/db/migrations/0039_auto_20230723_2203.py
@@ -0,0 +1,97 @@
+# Generated by Django 4.2.3 on 2023-07-23 16:33
+import random
+from django.db import migrations, models
+import plane.db.models.workspace
+
+
+def rename_field(apps, schema_editor):
+ Model = apps.get_model("db", "IssueActivity")
+ updated_activity = []
+ for obj in Model.objects.filter(field="assignee"):
+ obj.field = "assignees"
+ updated_activity.append(obj)
+
+ Model.objects.bulk_update(updated_activity, ["field"], batch_size=100)
+
+
+def update_workspace_member_props(apps, schema_editor):
+ Model = apps.get_model("db", "WorkspaceMember")
+
+ updated_workspace_member = []
+
+ for obj in Model.objects.all():
+ if obj.view_props is None:
+ obj.view_props = {
+ "filters": {"type": None},
+ "groupByProperty": None,
+ "issueView": "list",
+ "orderBy": "-created_at",
+ "properties": {
+ "assignee": True,
+ "due_date": True,
+ "key": True,
+ "labels": True,
+ "priority": True,
+ "state": True,
+ "sub_issue_count": True,
+ "attachment_count": True,
+ "link": True,
+ "estimate": True,
+ "created_on": True,
+ "updated_on": True,
+ },
+ "showEmptyGroups": True,
+ }
+ else:
+ current_view_props = obj.view_props
+ obj.view_props = {
+ "filters": {"type": None},
+ "groupByProperty": None,
+ "issueView": "list",
+ "orderBy": "-created_at",
+ "showEmptyGroups": True,
+ "properties": current_view_props,
+ }
+
+ updated_workspace_member.append(obj)
+
+ Model.objects.bulk_update(updated_workspace_member, ["view_props"], batch_size=100)
+
+
+def update_project_member_sort_order(apps, schema_editor):
+ Model = apps.get_model("db", "ProjectMember")
+
+ updated_project_members = []
+
+ for obj in Model.objects.all():
+ obj.sort_order = random.randint(1, 65536)
+ updated_project_members.append(obj)
+
+ Model.objects.bulk_update(updated_project_members, ["sort_order"], batch_size=100)
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("db", "0038_auto_20230720_1505"),
+ ]
+
+ operations = [
+ migrations.RunPython(rename_field),
+ migrations.RunPython(update_workspace_member_props),
+ migrations.AlterField(
+ model_name='workspacemember',
+ name='view_props',
+ field=models.JSONField(default=plane.db.models.workspace.get_default_props),
+ ),
+ migrations.AddField(
+ model_name='workspacemember',
+ name='default_props',
+ field=models.JSONField(default=plane.db.models.workspace.get_default_props),
+ ),
+ migrations.AddField(
+ model_name='projectmember',
+ name='sort_order',
+ field=models.FloatField(default=65535),
+ ),
+ migrations.RunPython(update_project_member_sort_order),
+ ]
diff --git a/apiserver/plane/db/migrations/0040_projectmember_preferences_user_cover_image_and_more.py b/apiserver/plane/db/migrations/0040_projectmember_preferences_user_cover_image_and_more.py
new file mode 100644
index 000000000..b7ca65500
--- /dev/null
+++ b/apiserver/plane/db/migrations/0040_projectmember_preferences_user_cover_image_and_more.py
@@ -0,0 +1,71 @@
+# Generated by Django 4.2.3 on 2023-08-01 06:02
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import plane.db.models.project
+import uuid
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('db', '0039_auto_20230723_2203'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='projectmember',
+ name='preferences',
+ field=models.JSONField(default=plane.db.models.project.get_default_preferences),
+ ),
+ migrations.AddField(
+ model_name='user',
+ name='cover_image',
+ field=models.URLField(blank=True, max_length=800, null=True),
+ ),
+ migrations.CreateModel(
+ name='IssueReaction',
+ fields=[
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
+ ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
+ ('reaction', models.CharField(max_length=20)),
+ ('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_reactions', to=settings.AUTH_USER_MODEL)),
+ ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
+ ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_reactions', to='db.issue')),
+ ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')),
+ ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
+ ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')),
+ ],
+ options={
+ 'verbose_name': 'Issue Reaction',
+ 'verbose_name_plural': 'Issue Reactions',
+ 'db_table': 'issue_reactions',
+ 'ordering': ('-created_at',),
+ 'unique_together': {('issue', 'actor', 'reaction')},
+ },
+ ),
+ migrations.CreateModel(
+ name='CommentReaction',
+ fields=[
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
+ ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
+ ('reaction', models.CharField(max_length=20)),
+ ('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_reactions', to=settings.AUTH_USER_MODEL)),
+ ('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_reactions', to='db.issuecomment')),
+ ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
+ ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')),
+ ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
+ ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')),
+ ],
+ options={
+ 'verbose_name': 'Comment Reaction',
+ 'verbose_name_plural': 'Comment Reactions',
+ 'db_table': 'comment_reactions',
+ 'ordering': ('-created_at',),
+ 'unique_together': {('comment', 'actor', 'reaction')},
+ },
+ ),
+ ]
diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py
index 1c075478d..959dea5f7 100644
--- a/apiserver/plane/db/models/__init__.py
+++ b/apiserver/plane/db/models/__init__.py
@@ -34,6 +34,8 @@ from .issue import (
IssueSequence,
IssueAttachment,
IssueSubscriber,
+ IssueReaction,
+ CommentReaction,
)
from .asset import FileAsset
diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py
index 3e9da02d5..2a4462942 100644
--- a/apiserver/plane/db/models/issue.py
+++ b/apiserver/plane/db/models/issue.py
@@ -209,7 +209,7 @@ class IssueAssignee(ProjectBaseModel):
class IssueLink(ProjectBaseModel):
- title = models.CharField(max_length=255, null=True)
+ title = models.CharField(max_length=255, null=True, blank=True)
url = models.URLField()
issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, related_name="issue_link"
@@ -424,6 +424,49 @@ class IssueSubscriber(ProjectBaseModel):
return f"{self.issue.name} {self.subscriber.email}"
+class IssueReaction(ProjectBaseModel):
+
+ actor = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.CASCADE,
+ related_name="issue_reactions",
+ )
+ issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_reactions")
+ reaction = models.CharField(max_length=20)
+
+ class Meta:
+ unique_together = ["issue", "actor", "reaction"]
+ verbose_name = "Issue Reaction"
+ verbose_name_plural = "Issue Reactions"
+ db_table = "issue_reactions"
+ ordering = ("-created_at",)
+
+ def __str__(self):
+ return f"{self.issue.name} {self.actor.email}"
+
+
+class CommentReaction(ProjectBaseModel):
+
+ actor = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.CASCADE,
+ related_name="comment_reactions",
+ )
+ comment = models.ForeignKey(IssueComment, on_delete=models.CASCADE, related_name="comment_reactions")
+ reaction = models.CharField(max_length=20)
+
+ class Meta:
+ unique_together = ["comment", "actor", "reaction"]
+ verbose_name = "Comment Reaction"
+ verbose_name_plural = "Comment Reactions"
+ db_table = "comment_reactions"
+ ordering = ("-created_at",)
+
+ def __str__(self):
+ return f"{self.issue.name} {self.actor.email}"
+
+
+
# TODO: Find a better method to save the model
@receiver(post_save, sender=Issue)
def create_issue_sequence(sender, instance, created, **kwargs):
diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py
index b28cbc69e..2cbd70369 100644
--- a/apiserver/plane/db/models/project.py
+++ b/apiserver/plane/db/models/project.py
@@ -31,6 +31,13 @@ def get_default_props():
"showEmptyGroups": True,
}
+def get_default_preferences():
+ return {
+ "pages": {
+ "block_display": True
+ }
+ }
+
class Project(BaseModel):
NETWORK_CHOICES = ((0, "Secret"), (2, "Public"))
@@ -47,7 +54,7 @@ class Project(BaseModel):
"db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_project"
)
identifier = models.CharField(
- max_length=5,
+ max_length=12,
verbose_name="Project Identifier",
)
default_assignee = models.ForeignKey(
@@ -147,6 +154,21 @@ class ProjectMember(ProjectBaseModel):
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10)
view_props = models.JSONField(default=get_default_props)
default_props = models.JSONField(default=get_default_props)
+ preferences = models.JSONField(default=get_default_preferences)
+ sort_order = models.FloatField(default=65535)
+
+
+ def save(self, *args, **kwargs):
+ if self._state.adding:
+ smallest_sort_order = ProjectMember.objects.filter(
+ workspace_id=self.project.workspace_id, member=self.member
+ ).aggregate(smallest=models.Min("sort_order"))["smallest"]
+
+ # Project ordering
+ if smallest_sort_order is not None:
+ self.sort_order = smallest_sort_order - 10000
+
+ super(ProjectMember, self).save(*args, **kwargs)
class Meta:
unique_together = ["project", "member"]
@@ -168,7 +190,7 @@ class ProjectIdentifier(AuditModel):
project = models.OneToOneField(
Project, on_delete=models.CASCADE, related_name="project_identifier"
)
- name = models.CharField(max_length=10)
+ name = models.CharField(max_length=12)
class Meta:
unique_together = ["name", "workspace"]
diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py
index 36b3a1f6b..0b643271e 100644
--- a/apiserver/plane/db/models/user.py
+++ b/apiserver/plane/db/models/user.py
@@ -38,6 +38,7 @@ class User(AbstractBaseUser, PermissionsMixin):
first_name = models.CharField(max_length=255, blank=True)
last_name = models.CharField(max_length=255, blank=True)
avatar = models.CharField(max_length=255, blank=True)
+ cover_image = models.URLField(blank=True, null=True, max_length=800)
# tracking metrics
date_joined = models.DateTimeField(auto_now_add=True, verbose_name="Created At")
diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py
index 9b9fbb68c..09db42002 100644
--- a/apiserver/plane/db/models/workspace.py
+++ b/apiserver/plane/db/models/workspace.py
@@ -14,6 +14,30 @@ ROLE_CHOICES = (
)
+def get_default_props():
+ return {
+ "filters": {"type": None},
+ "groupByProperty": None,
+ "issueView": "list",
+ "orderBy": "-created_at",
+ "properties": {
+ "assignee": True,
+ "due_date": True,
+ "key": True,
+ "labels": True,
+ "priority": True,
+ "state": True,
+ "sub_issue_count": True,
+ "attachment_count": True,
+ "link": True,
+ "estimate": True,
+ "created_on": True,
+ "updated_on": True,
+ },
+ "showEmptyGroups": True,
+ }
+
+
class Workspace(BaseModel):
name = models.CharField(max_length=80, verbose_name="Workspace Name")
logo = models.URLField(verbose_name="Logo", blank=True, null=True)
@@ -47,7 +71,8 @@ class WorkspaceMember(BaseModel):
)
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10)
company_role = models.TextField(null=True, blank=True)
- view_props = models.JSONField(null=True, blank=True)
+ view_props = models.JSONField(default=get_default_props)
+ default_props = models.JSONField(default=get_default_props)
class Meta:
unique_together = ["workspace", "member"]
diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py
index 6a9e8b8e8..a7a946e60 100644
--- a/apiserver/plane/utils/issue_filters.py
+++ b/apiserver/plane/utils/issue_filters.py
@@ -12,6 +12,18 @@ def filter_state(params, filter, method):
return filter
+def filter_state_group(params, filter, method):
+ if method == "GET":
+ state_group = params.get("state_group").split(",")
+ if len(state_group) and "" not in state_group:
+ filter["state__group__in"] = state_group
+ else:
+ if params.get("state_group", None) and len(params.get("state_group")):
+ filter["state__group__in"] = params.get("state_group")
+ return filter
+
+
+
def filter_estimate_point(params, filter, method):
if method == "GET":
estimate_points = params.get("estimate_point").split(",")
@@ -212,6 +224,7 @@ def filter_issue_state_type(params, filter, method):
return filter
+
def filter_project(params, filter, method):
if method == "GET":
projects = params.get("project").split(",")
@@ -268,11 +281,24 @@ def filter_sub_issue_toggle(params, filter, method):
return filter
+def filter_subscribed_issues(params, filter, method):
+ if method == "GET":
+ subscribers = params.get("subscriber").split(",")
+ if len(subscribers) and "" not in subscribers:
+ filter["issue_subscribers__subscriber_id__in"] = subscribers
+ else:
+ if params.get("subscriber", None) and len(params.get("subscriber")):
+ filter["issue_subscribers__subscriber_id__in"] = params.get("subscriber")
+ return filter
+
+
def issue_filters(query_params, method):
filter = dict()
+ print(query_params)
ISSUE_FILTER = {
"state": filter_state,
+ "state_group": filter_state_group,
"estimate_point": filter_estimate_point,
"priority": filter_priority,
"parent": filter_parent,
@@ -291,6 +317,7 @@ def issue_filters(query_params, method):
"module": filter_module,
"inbox_status": filter_inbox_status,
"sub_issue": filter_sub_issue_toggle,
+ "subscriber": filter_subscribed_issues,
}
for key, value in ISSUE_FILTER.items():
diff --git a/apiserver/templates/emails/auth/magic_signin.html b/apiserver/templates/emails/auth/magic_signin.html
index bc7ed12aa..63fbe5e32 100644
--- a/apiserver/templates/emails/auth/magic_signin.html
+++ b/apiserver/templates/emails/auth/magic_signin.html
@@ -89,8 +89,8 @@
-
+
Open Plane
-
+
|
@@ -364,4 +364,4 @@