feat: workspace views (#2005)

* feat: workspace views

* fix: added project member filter

* fix: added pagination in workspace views

* fix: filters and group up by for workspace issues

* fix: changed name workspace view to global view

* fix: reordered the urls
This commit is contained in:
Bavisetti Narayan 2023-09-19 19:45:37 +05:30 committed by GitHub
parent 926d2ae0a0
commit 405ef9314f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 311 additions and 14 deletions

View File

@ -23,7 +23,7 @@ from .project import (
ProjectPublicMemberSerializer ProjectPublicMemberSerializer
) )
from .state import StateSerializer, StateLiteSerializer from .state import StateSerializer, StateLiteSerializer
from .view import IssueViewSerializer, IssueViewFavoriteSerializer from .view import GlobalViewSerializer, IssueViewSerializer, IssueViewFavoriteSerializer
from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer, CycleWriteSerializer from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer, CycleWriteSerializer
from .asset import FileAssetSerializer from .asset import FileAssetSerializer
from .issue import ( from .issue import (

View File

@ -5,10 +5,39 @@ from rest_framework import serializers
from .base import BaseSerializer from .base import BaseSerializer
from .workspace import WorkspaceLiteSerializer from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer from .project import ProjectLiteSerializer
from plane.db.models import IssueView, IssueViewFavorite from plane.db.models import GlobalView, IssueView, IssueViewFavorite
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
class GlobalViewSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
class Meta:
model = GlobalView
fields = "__all__"
read_only_fields = [
"workspace",
"query",
]
def create(self, validated_data):
query_params = validated_data.get("query_data", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
validated_data["query"] = dict()
return GlobalView.objects.create(**validated_data)
def update(self, instance, validated_data):
query_params = validated_data.get("query_data", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
validated_data["query"] = dict()
validated_data["query"] = issue_filters(query_params, "PATCH")
return super().update(instance, validated_data)
class IssueViewSerializer(BaseSerializer): class IssueViewSerializer(BaseSerializer):
is_favorite = serializers.BooleanField(read_only=True) is_favorite = serializers.BooleanField(read_only=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True) project_detail = ProjectLiteSerializer(source="project", read_only=True)

View File

@ -102,6 +102,8 @@ from plane.api.views import (
BulkEstimatePointEndpoint, BulkEstimatePointEndpoint,
## End Estimates ## End Estimates
# Views # Views
GlobalViewViewSet,
GlobalViewIssuesViewSet,
IssueViewViewSet, IssueViewViewSet,
ViewIssuesEndpoint, ViewIssuesEndpoint,
IssueViewFavoriteViewSet, IssueViewFavoriteViewSet,
@ -184,7 +186,6 @@ from plane.api.views import (
## Exporter ## Exporter
ExportIssuesEndpoint, ExportIssuesEndpoint,
## End Exporter ## End Exporter
) )
@ -241,7 +242,11 @@ urlpatterns = [
UpdateUserTourCompletedEndpoint.as_view(), UpdateUserTourCompletedEndpoint.as_view(),
name="user-tour", name="user-tour",
), ),
path("users/workspaces/<str:slug>/activities/", UserActivityEndpoint.as_view(), name="user-activities"), path(
"users/workspaces/<str:slug>/activities/",
UserActivityEndpoint.as_view(),
name="user-activities",
),
# user workspaces # user workspaces
path( path(
"users/me/workspaces/", "users/me/workspaces/",
@ -649,6 +654,37 @@ urlpatterns = [
ViewIssuesEndpoint.as_view(), ViewIssuesEndpoint.as_view(),
name="project-view-issues", name="project-view-issues",
), ),
path(
"workspaces/<str:slug>/views/",
GlobalViewViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="global-view",
),
path(
"workspaces/<str:slug>/views/<uuid:pk>/",
GlobalViewViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="global-view",
),
path(
"workspaces/<str:slug>/issues/",
GlobalViewIssuesViewSet.as_view(
{
"get": "list",
}
),
name="global-view-issues",
),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-views/", "workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-views/",
IssueViewFavoriteViewSet.as_view( IssueViewFavoriteViewSet.as_view(
@ -767,11 +803,6 @@ urlpatterns = [
), ),
name="project-issue", name="project-issue",
), ),
path(
"workspaces/<str:slug>/issues/",
WorkSpaceIssuesEndpoint.as_view(),
name="workspace-issue",
),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/", "workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/",
LabelViewSet.as_view( LabelViewSet.as_view(

View File

@ -56,7 +56,7 @@ from .workspace import (
LeaveWorkspaceEndpoint, LeaveWorkspaceEndpoint,
) )
from .state import StateViewSet from .state import StateViewSet
from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet from .view import GlobalViewViewSet, GlobalViewIssuesViewSet, IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
from .cycle import ( from .cycle import (
CycleViewSet, CycleViewSet,
CycleIssueViewSet, CycleIssueViewSet,

View File

@ -1,4 +1,18 @@
# Django imports # Django imports
from django.db.models import (
Prefetch,
OuterRef,
Func,
F,
Case,
Value,
CharField,
When,
Exists,
Max,
)
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.db import IntegrityError from django.db import IntegrityError
from django.db.models import Prefetch, OuterRef, Exists from django.db.models import Prefetch, OuterRef, Exists
@ -10,18 +24,192 @@ from sentry_sdk import capture_exception
# Module imports # Module imports
from . import BaseViewSet, BaseAPIView from . import BaseViewSet, BaseAPIView
from plane.api.serializers import ( from plane.api.serializers import (
GlobalViewSerializer,
IssueViewSerializer, IssueViewSerializer,
IssueLiteSerializer, IssueLiteSerializer,
IssueViewFavoriteSerializer, IssueViewFavoriteSerializer,
) )
from plane.api.permissions import ProjectEntityPermission from plane.api.permissions import WorkspaceEntityPermission, ProjectEntityPermission
from plane.db.models import ( from plane.db.models import (
Workspace,
GlobalView,
IssueView, IssueView,
Issue, Issue,
IssueViewFavorite, IssueViewFavorite,
IssueReaction, IssueReaction,
IssueLink,
IssueAttachment,
) )
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.utils.grouper import group_results
class GlobalViewViewSet(BaseViewSet):
serializer_class = GlobalViewSerializer
model = GlobalView
permission_classes = [
WorkspaceEntityPermission,
]
def perform_create(self, serializer):
workspace = Workspace.objects.get(slug=self.kwargs.get("slug"))
serializer.save(workspace_id=workspace.id)
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace")
.order_by("-created_at")
.distinct()
)
class GlobalViewIssuesViewSet(BaseViewSet):
permission_classes = [
WorkspaceEntityPermission,
]
def get_queryset(self):
return (
Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("project")
.select_related("workspace")
.select_related("state")
.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):
try:
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 = (
self.get_queryset()
.filter(**filters)
.filter(project__project_projectmember__member=self.request.user)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.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")
)
)
# 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)
sub_group_by = request.GET.get("sub_group_by", False)
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by:
return Response(
group_results(issues, group_by, sub_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 IssueViewViewSet(BaseViewSet): class IssueViewViewSet(BaseViewSet):

View File

@ -1,6 +1,10 @@
# Generated by Django 4.2.3 on 2023-09-15 06:55 # Generated by Django 4.2.3 on 2023-09-15 06:55
from django.db import migrations from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
def update_issue_activity(apps, schema_editor): def update_issue_activity(apps, schema_editor):
IssueActivityModel = apps.get_model("db", "IssueActivity") IssueActivityModel = apps.get_model("db", "IssueActivity")
@ -19,5 +23,27 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
migrations.CreateModel(
name='GlobalView',
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)),
('name', models.CharField(max_length=255, verbose_name='View Name')),
('description', models.TextField(blank=True, verbose_name='View Description')),
('query', models.JSONField(verbose_name='View Query')),
('access', models.PositiveSmallIntegerField(choices=[(0, 'Private'), (1, 'Public')], default=1)),
('query_data', models.JSONField(default=dict)),
('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')),
('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='global_views', to='db.workspace')),
],
options={
'verbose_name': 'Global View',
'verbose_name_plural': 'Global Views',
'db_table': 'global_views',
'ordering': ('-created_at',),
},
),
migrations.RunPython(update_issue_activity), migrations.RunPython(update_issue_activity),
] ]

View File

@ -50,7 +50,7 @@ from .state import State
from .cycle import Cycle, CycleIssue, CycleFavorite from .cycle import Cycle, CycleIssue, CycleFavorite
from .view import IssueView, IssueViewFavorite from .view import GlobalView, IssueView, IssueViewFavorite
from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite

View File

@ -3,7 +3,30 @@ from django.db import models
from django.conf import settings from django.conf import settings
# Module import # Module import
from . import ProjectBaseModel from . import ProjectBaseModel, BaseModel
class GlobalView(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="global_views"
)
name = models.CharField(max_length=255, verbose_name="View Name")
description = models.TextField(verbose_name="View Description", blank=True)
query = models.JSONField(verbose_name="View Query")
access = models.PositiveSmallIntegerField(
default=1, choices=((0, "Private"), (1, "Public"))
)
query_data = models.JSONField(default=dict)
class Meta:
verbose_name = "Global View"
verbose_name_plural = "Global Views"
db_table = "global_views"
ordering = ("-created_at",)
def __str__(self):
"""Return name of the View"""
return f"{self.name} <{self.workspace.name}>"
class IssueView(ProjectBaseModel): class IssueView(ProjectBaseModel):