feat: issue filter views (#418)

* dev: views initiated

* dev: refactor filtering logic

* dev: move state grouping filter to util function

* dev: view issues create endpoint and update on filters for time

* dev: rename views to issue views

* dev: rename in serilaizer and views

* dev: update issue filters

* dev: update filter

* feat: create issue favorites

* dev: update query keys

* dev: update create and update method
This commit is contained in:
pablohashescobar 2023-03-15 23:25:09 +05:30 committed by GitHub
parent 46f6b61928
commit b6ee197b40
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 495 additions and 30 deletions

View File

@ -21,7 +21,7 @@ from .project import (
) )
from .state import StateSerializer from .state import StateSerializer
from .shortcut import ShortCutSerializer from .shortcut import ShortCutSerializer
from .view import ViewSerializer from .view import IssueViewSerializer, IssueViewFavoriteSerializer
from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer
from .asset import FileAssetSerializer from .asset import FileAssetSerializer
from .issue import ( from .issue import (

View File

@ -1,14 +1,55 @@
# Third party imports
from rest_framework import serializers
# Module imports # Module imports
from .base import BaseSerializer from .base import BaseSerializer
from plane.db.models import View from plane.db.models import IssueView, IssueViewFavorite
from plane.utils.issue_filters import issue_filters
class ViewSerializer(BaseSerializer): class IssueViewSerializer(BaseSerializer):
is_favorite = serializers.BooleanField(read_only=True)
class Meta: class Meta:
model = View model = IssueView
fields = "__all__" fields = "__all__"
read_only_fields = [ read_only_fields = [
"workspace", "workspace",
"project", "project",
"query",
]
def create(self, validated_data):
query_params = validated_data.get("query_data", {})
if not bool(query_params):
raise serializers.ValidationError(
{"query_data": ["Query data field cannot be empty"]}
)
validated_data["query"] = issue_filters(query_params, "POST")
return IssueView.objects.create(**validated_data)
def update(self, instance, validated_data):
query_params = validated_data.get("query_data", {})
if not bool(query_params):
raise serializers.ValidationError(
{"query_data": ["Query data field cannot be empty"]}
)
validated_data["query"] = issue_filters(query_params, "PATCH")
return super().update(instance, validated_data)
class IssueViewFavoriteSerializer(BaseSerializer):
view_detail = IssueViewSerializer(source="issue_view", read_only=True)
class Meta:
model = IssueViewFavorite
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"user",
] ]

View File

@ -79,7 +79,9 @@ from plane.api.views import (
ShortCutViewSet, ShortCutViewSet,
## End Shortcuts ## End Shortcuts
# Views # Views
ViewViewSet, IssueViewViewSet,
ViewIssuesEndpoint,
IssueViewFavoriteViewSet,
## End Views ## End Views
# Cycles # Cycles
CycleViewSet, CycleViewSet,
@ -474,7 +476,7 @@ urlpatterns = [
# Views # Views
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/views/", "workspaces/<str:slug>/projects/<uuid:project_id>/views/",
ViewViewSet.as_view( IssueViewViewSet.as_view(
{ {
"get": "list", "get": "list",
"post": "create", "post": "create",
@ -484,7 +486,7 @@ urlpatterns = [
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/", "workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/",
ViewViewSet.as_view( IssueViewViewSet.as_view(
{ {
"get": "retrieve", "get": "retrieve",
"put": "update", "put": "update",
@ -494,6 +496,30 @@ urlpatterns = [
), ),
name="project-view", name="project-view",
), ),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:view_id>/issues/",
ViewIssuesEndpoint.as_view(),
name="project-view-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-views/",
IssueViewFavoriteViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="user-favorite-view",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-views/<uuid:view_id>/",
IssueViewFavoriteViewSet.as_view(
{
"delete": "destroy",
}
),
name="user-favorite-view",
),
## End Views ## End Views
## Cycles ## Cycles
path( path(

View File

@ -41,7 +41,7 @@ from .workspace import (
) )
from .state import StateViewSet from .state import StateViewSet
from .shortcut import ShortCutViewSet from .shortcut import ShortCutViewSet
from .view import ViewViewSet from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
from .cycle import ( from .cycle import (
CycleViewSet, CycleViewSet,
CycleIssueViewSet, CycleIssueViewSet,

View File

@ -7,6 +7,7 @@ from itertools import groupby, chain
from django.db.models import Prefetch, OuterRef, Func, F, Q from django.db.models import Prefetch, OuterRef, Func, F, Q
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
# Third Party imports # Third Party imports
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
@ -46,6 +47,7 @@ from plane.db.models import (
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters
class IssueViewSet(BaseViewSet): class IssueViewSet(BaseViewSet):
@ -172,18 +174,11 @@ class IssueViewSet(BaseViewSet):
def list(self, request, slug, project_id): def list(self, request, slug, project_id):
try: try:
# Issue State groups filters = issue_filters(request.query_params, "GET")
type = request.GET.get("type", "all")
group = ["backlog", "unstarted", "started", "completed", "cancelled"]
if type == "backlog":
group = ["backlog"]
if type == "active":
group = ["unstarted", "started"]
issue_queryset = ( issue_queryset = (
self.get_queryset() self.get_queryset()
.order_by(request.GET.get("order_by", "created_at")) .order_by(request.GET.get("order_by", "created_at"))
.filter(state__group__in=group) .filter(**filters)
) )
issues = IssueSerializer(issue_queryset, many=True).data issues = IssueSerializer(issue_queryset, many=True).data

View File

@ -1,14 +1,34 @@
# Django imports
from django.db import IntegrityError
from django.db.models import Prefetch, OuterRef, Exists
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from sentry_sdk import capture_exception
# Module imports # Module imports
from . import BaseViewSet from . import BaseViewSet, BaseAPIView
from plane.api.serializers import ViewSerializer from plane.api.serializers import (
IssueViewSerializer,
IssueSerializer,
IssueViewFavoriteSerializer,
)
from plane.api.permissions import ProjectEntityPermission from plane.api.permissions import ProjectEntityPermission
from plane.db.models import View from plane.db.models import (
IssueView,
Issue,
IssueBlocker,
IssueLink,
CycleIssue,
ModuleIssue,
IssueViewFavorite,
)
class ViewViewSet(BaseViewSet): class IssueViewViewSet(BaseViewSet):
serializer_class = IssueViewSerializer
serializer_class = ViewSerializer model = IssueView
model = View
permission_classes = [ permission_classes = [
ProjectEntityPermission, ProjectEntityPermission,
] ]
@ -17,6 +37,12 @@ class ViewViewSet(BaseViewSet):
serializer.save(project_id=self.kwargs.get("project_id")) serializer.save(project_id=self.kwargs.get("project_id"))
def get_queryset(self): def get_queryset(self):
subquery = IssueViewFavorite.objects.filter(
user=self.request.user,
view_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
return self.filter_queryset( return self.filter_queryset(
super() super()
.get_queryset() .get_queryset()
@ -25,5 +51,142 @@ class ViewViewSet(BaseViewSet):
.filter(project__project_projectmember__member=self.request.user) .filter(project__project_projectmember__member=self.request.user)
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")
.annotate(is_favorite=Exists(subquery))
.distinct() .distinct()
) )
class ViewIssuesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id, view_id):
try:
view = IssueView.objects.get(pk=view_id)
queries = view.query
issues = (
Issue.objects.filter(
**queries, project_id=project_id, workspace__slug=slug
)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"blocked_issues",
queryset=IssueBlocker.objects.select_related(
"blocked_by", "block"
),
)
)
.prefetch_related(
Prefetch(
"blocker_issues",
queryset=IssueBlocker.objects.select_related(
"block", "blocked_by"
),
)
)
.prefetch_related(
Prefetch(
"issue_cycle",
queryset=CycleIssue.objects.select_related("cycle", "issue"),
),
)
.prefetch_related(
Prefetch(
"issue_module",
queryset=ModuleIssue.objects.select_related(
"module", "issue"
).prefetch_related("module__members"),
),
)
.prefetch_related(
Prefetch(
"issue_link",
queryset=IssueLink.objects.select_related(
"issue"
).select_related("created_by"),
)
)
)
serializer = IssueSerializer(issues, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except IssueView.DoesNotExist:
return Response(
{"error": "Issue View does not exist"}, status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class IssueViewFavoriteViewSet(BaseViewSet):
serializer_class = IssueViewFavoriteSerializer
model = IssueViewFavorite
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(user=self.request.user)
.select_related("view")
)
def create(self, request, slug, project_id):
try:
serializer = IssueViewFavoriteSerializer(data=request.data)
if serializer.is_valid():
serializer.save(user=request.user, project_id=project_id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError as e:
if "already exists" in str(e):
return Response(
{"error": "The view is already added to favorites"},
status=status.HTTP_410_GONE,
)
else:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
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,
)
def destroy(self, request, slug, project_id, view_id):
try:
view_favourite = IssueViewFavorite.objects.get(
project=project_id,
user=request.user,
workspace__slug=slug,
view_id=view_id,
)
view_favourite.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except IssueViewFavorite.DoesNotExist:
return Response(
{"error": "View is not in favorites"},
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,
)

View File

@ -44,7 +44,7 @@ from .cycle import Cycle, CycleIssue, CycleFavorite
from .shortcut import Shortcut from .shortcut import Shortcut
from .view import View from .view import IssueView, IssueViewFavorite
from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite

View File

@ -1,22 +1,48 @@
# Django imports # Django imports
from django.db import models from django.db import models
from django.conf import settings
# Module import # Module import
from . import ProjectBaseModel from . import ProjectBaseModel
class View(ProjectBaseModel): class IssueView(ProjectBaseModel):
name = models.CharField(max_length=255, verbose_name="View Name") name = models.CharField(max_length=255, verbose_name="View Name")
description = models.TextField(verbose_name="View Description", blank=True) description = models.TextField(verbose_name="View Description", blank=True)
query = models.JSONField(verbose_name="View Query") 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: class Meta:
verbose_name = "View" verbose_name = "Issue View"
verbose_name_plural = "Views" verbose_name_plural = "Issue Views"
db_table = "views" db_table = "issue_views"
ordering = ("-created_at",) ordering = ("-created_at",)
def __str__(self): def __str__(self):
"""Return name of the View""" """Return name of the View"""
return f"{self.name} <{self.project.name}>" return f"{self.name} <{self.project.name}>"
class IssueViewFavorite(ProjectBaseModel):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="user_view_favorites",
)
view = models.ForeignKey(
"db.IssueView", on_delete=models.CASCADE, related_name="view_favorites"
)
class Meta:
unique_together = ["view", "user"]
verbose_name = "View Favorite"
verbose_name_plural = "View Favorites"
db_table = "view_favorites"
ordering = ("-created_at",)
def __str__(self):
"""Return user and the view"""
return f"{self.user.email} <{self.view.name}>"

View File

@ -0,0 +1,214 @@
from django.utils.timezone import make_aware
from django.utils.dateparse import parse_datetime
def filter_state(params, filter, method):
if method == "GET":
states = params.get("state").split(",")
if len(states) and "" not in states:
filter["state__in"] = states
else:
if len(params.get("state")):
filter["state__in"] = params.get("state")
return filter
def filter_priority(params, filter, method):
if method == "GET":
priorties = params.get("priority").split(",")
if len(priorties) and "" not in priorties:
filter["priority__in"] = priorties
else:
if len(params.get("priority")):
filter["priority__in"] = params.get("priority")
return filter
def filter_parent(params, filter, method):
if method == "GET":
parents = params.get("parent").split(",")
if len(parents) and "" not in parents:
filter["parent__in"] = parents
else:
if len(params.get("parent")):
filter["parent__in"] = params.get("parent")
return filter
def filter_labels(params, filter, method):
if method == "GET":
labels = params.get("labels").split(",")
if len(labels) and "" not in labels:
filter["labels__in"] = labels
else:
if len(params.get("labels")):
filter["labels__in"] = params.get("labels")
return filter
def filter_assignees(params, filter, method):
if method == "GET":
assignees = params.get("assignees").split(",")
if len(assignees) and "" not in assignees:
filter["assignees__in"] = assignees
else:
if len(params.get("assignees")):
filter["assignees__in"] = params.get("assignees")
return filter
def filter_created_by(params, filter, method):
if method == "GET":
created_bys = params.get("created_by").split(",")
if len(created_bys) and "" not in created_bys:
filter["created_by__in"] = created_bys
else:
if len(params.get("created_by")):
filter["created_by__in"] = params.get("created_by")
return filter
def filter_name(params, filter, method):
if params.get("name", "") != "":
filter["name__icontains"] = params.get("name")
return filter
def filter_created_at(params, filter, method):
if method == "GET":
created_ats = params.get("created_at").split(",")
if len(created_ats) and "" not in created_ats:
for query in created_ats:
created_at_query = query.split(";")
if len(created_at_query) == 2 and "after" in created_at_query:
filter["created_at__date__gte"] = created_at_query[0]
else:
filter["created_at__date__lte"] = created_at_query[0]
else:
if len(params.get("created_at")):
for query in params.get("created_at"):
if query.get("timeline", "after") == "after":
filter["created_at__date__gte"] = query.get("datetime")
else:
filter["created_at__date__lte"] = query.get("datetime")
return filter
def filter_updated_at(params, filter, method):
if method == "GET":
updated_bys = params.get("updated_at").split(",")
if len(updated_bys) and "" not in updated_bys:
for query in updated_bys:
updated_at_query = query.split(";")
if len(updated_at_query) == 2 and "after" in updated_at_query:
filter["updated_at__date__gte"] = updated_at_query[0]
else:
filter["updated_at__date__lte"] = updated_at_query[0]
else:
if len(params.get("updated_at")):
for query in params.get("updated_at"):
if query.get("timeline", "after") == "after":
filter["updated_at__date__gte"] = query.get("datetime")
else:
filter["updated_at__date__lte"] = query.get("datetime")
return filter
def filter_start_date(params, filter, method):
if method == "GET":
start_dates = params.get("start_date").split(";")
if len(start_dates) and "" not in start_dates:
for query in start_dates:
start_date_query = query.split(";")
if len(start_date_query) == 2 and "after" in start_date_query:
filter["start_date__date__gte"] = start_date_query[0]
else:
filter["start_date__date__lte"] = start_date_query[0]
else:
if len(params.get("start_date")):
for query in params.get("start_date"):
if query.get("timeline", "after") == "after":
filter["start_date__date__gte"] = query.get("datetime")
else:
filter["start_date__date__lte"] = query.get("datetime")
return filter
def filter_target_date(params, filter, method):
if method == "GET":
target_dates = params.get("target_date").split(";")
if len(target_dates) and "" not in target_dates:
for query in target_dates:
target_date_query = query.split(";")
if len(target_date_query) == 2 and "after" in target_date_query:
filter["target_date__date__gte"] = target_date_query[0]
else:
filter["target_date__date__lte"] = target_date_query[0]
else:
if len(params.get("target_date")):
for query in params.get("target_date"):
if query.get("timeline", "after") == "after":
filter["target_date__date__gte"] = query.get("datetime")
else:
filter["target_date__date__lte"] = query.get("datetime")
return filter
def filter_completed_at(params, filter, method):
if method == "GET":
completed_ats = params.get("completed_at").split(",")
if len(completed_ats) and "" not in completed_ats:
for query in completed_ats:
completed_at_query = query.split(";")
if len(completed_at_query) == 2 and "after" in completed_at_query:
filter["completed_at__date__gte"] = completed_at_query[0]
else:
filter["completed_at__lte"] = completed_at_query[0]
else:
if len(params.get("completed_at")):
for query in params.get("completed_at"):
if query.get("timeline", "after") == "after":
filter["completed_at__date__gte"] = query.get("datetime")
else:
filter["completed_at__lte"] = query.get("datetime")
return filter
def filter_issue_state_type(params, filter, method):
type = params.get("type", "all")
group = ["backlog", "unstarted", "started", "completed", "cancelled"]
if type == "backlog":
group = ["backlog"]
if type == "active":
group = ["unstarted", "started"]
filter["state__group__in"] = group
return filter
def issue_filters(query_params, method):
filter = dict()
ISSUE_FILTER = {
"state": filter_state,
"priority": filter_priority,
"parent": filter_parent,
"labels": filter_labels,
"assignees": filter_assignees,
"created_by": filter_created_by,
"name": filter_name,
"created_at": filter_created_at,
"updated_at": filter_updated_at,
"start_date": filter_start_date,
"target_date": filter_target_date,
"completed_at": filter_completed_at,
"type": filter_issue_state_type,
}
for key, value in ISSUE_FILTER.items():
if key in query_params:
func = value
func(query_params, filter, method)
return filter