From b6ee197b4027b30c4e6348971f7502b343b0aaa4 Mon Sep 17 00:00:00 2001 From: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com> Date: Wed, 15 Mar 2023 23:25:09 +0530 Subject: [PATCH] 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 --- apiserver/plane/api/serializers/__init__.py | 2 +- apiserver/plane/api/serializers/view.py | 47 ++++- apiserver/plane/api/urls.py | 32 ++- apiserver/plane/api/views/__init__.py | 2 +- apiserver/plane/api/views/issue.py | 13 +- apiserver/plane/api/views/view.py | 177 +++++++++++++++- apiserver/plane/db/models/__init__.py | 2 +- apiserver/plane/db/models/view.py | 36 +++- apiserver/plane/utils/issue_filters.py | 214 ++++++++++++++++++++ 9 files changed, 495 insertions(+), 30 deletions(-) create mode 100644 apiserver/plane/utils/issue_filters.py diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 9a5b04b77..5481ba6c6 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -21,7 +21,7 @@ from .project import ( ) from .state import StateSerializer from .shortcut import ShortCutSerializer -from .view import ViewSerializer +from .view import IssueViewSerializer, IssueViewFavoriteSerializer from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer from .asset import FileAssetSerializer from .issue import ( diff --git a/apiserver/plane/api/serializers/view.py b/apiserver/plane/api/serializers/view.py index 23ac768ef..b998aace3 100644 --- a/apiserver/plane/api/serializers/view.py +++ b/apiserver/plane/api/serializers/view.py @@ -1,14 +1,55 @@ +# Third party imports +from rest_framework import serializers + # Module imports 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: - model = View + model = IssueView fields = "__all__" read_only_fields = [ "workspace", "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", ] diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index ab3a2f9d8..f9b99f181 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -79,7 +79,9 @@ from plane.api.views import ( ShortCutViewSet, ## End Shortcuts # Views - ViewViewSet, + IssueViewViewSet, + ViewIssuesEndpoint, + IssueViewFavoriteViewSet, ## End Views # Cycles CycleViewSet, @@ -474,7 +476,7 @@ urlpatterns = [ # Views path( "workspaces//projects//views/", - ViewViewSet.as_view( + IssueViewViewSet.as_view( { "get": "list", "post": "create", @@ -484,7 +486,7 @@ urlpatterns = [ ), path( "workspaces//projects//views//", - ViewViewSet.as_view( + IssueViewViewSet.as_view( { "get": "retrieve", "put": "update", @@ -494,6 +496,30 @@ urlpatterns = [ ), name="project-view", ), + path( + "workspaces//projects//views//issues/", + ViewIssuesEndpoint.as_view(), + name="project-view-issues", + ), + path( + "workspaces//projects//user-favorite-views/", + IssueViewFavoriteViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="user-favorite-view", + ), + path( + "workspaces//projects//user-favorite-views//", + IssueViewFavoriteViewSet.as_view( + { + "delete": "destroy", + } + ), + name="user-favorite-view", + ), ## End Views ## Cycles path( diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index d7cfb2bb4..8a95d69f2 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -41,7 +41,7 @@ from .workspace import ( ) from .state import StateViewSet from .shortcut import ShortCutViewSet -from .view import ViewViewSet +from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet from .cycle import ( CycleViewSet, CycleIssueViewSet, diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 0d16565ca..9dbcf55d8 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -7,6 +7,7 @@ from itertools import groupby, chain from django.db.models import Prefetch, OuterRef, Func, F, Q from django.core.serializers.json import DjangoJSONEncoder + # Third Party imports from rest_framework.response import Response 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.utils.grouper import group_results +from plane.utils.issue_filters import issue_filters class IssueViewSet(BaseViewSet): @@ -172,18 +174,11 @@ class IssueViewSet(BaseViewSet): def list(self, request, slug, project_id): try: - # Issue State groups - type = request.GET.get("type", "all") - group = ["backlog", "unstarted", "started", "completed", "cancelled"] - if type == "backlog": - group = ["backlog"] - if type == "active": - group = ["unstarted", "started"] - + filters = issue_filters(request.query_params, "GET") issue_queryset = ( self.get_queryset() .order_by(request.GET.get("order_by", "created_at")) - .filter(state__group__in=group) + .filter(**filters) ) issues = IssueSerializer(issue_queryset, many=True).data diff --git a/apiserver/plane/api/views/view.py b/apiserver/plane/api/views/view.py index 4ae4ff2c1..6c4241d7e 100644 --- a/apiserver/plane/api/views/view.py +++ b/apiserver/plane/api/views/view.py @@ -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 -from . import BaseViewSet -from plane.api.serializers import ViewSerializer +from . import BaseViewSet, BaseAPIView +from plane.api.serializers import ( + IssueViewSerializer, + IssueSerializer, + IssueViewFavoriteSerializer, +) 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): - - serializer_class = ViewSerializer - model = View +class IssueViewViewSet(BaseViewSet): + serializer_class = IssueViewSerializer + model = IssueView permission_classes = [ ProjectEntityPermission, ] @@ -17,6 +37,12 @@ class ViewViewSet(BaseViewSet): serializer.save(project_id=self.kwargs.get("project_id")) 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( super() .get_queryset() @@ -25,5 +51,142 @@ class ViewViewSet(BaseViewSet): .filter(project__project_projectmember__member=self.request.user) .select_related("project") .select_related("workspace") + .annotate(is_favorite=Exists(subquery)) .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, + ) diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index edd65cbc9..05507a00e 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -44,7 +44,7 @@ from .cycle import Cycle, CycleIssue, CycleFavorite from .shortcut import Shortcut -from .view import View +from .view import IssueView, IssueViewFavorite from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite diff --git a/apiserver/plane/db/models/view.py b/apiserver/plane/db/models/view.py index c3ea9a866..6a968af53 100644 --- a/apiserver/plane/db/models/view.py +++ b/apiserver/plane/db/models/view.py @@ -1,22 +1,48 @@ # Django imports from django.db import models - +from django.conf import settings # Module import from . import ProjectBaseModel -class View(ProjectBaseModel): +class IssueView(ProjectBaseModel): 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 = "View" - verbose_name_plural = "Views" - db_table = "views" + verbose_name = "Issue View" + verbose_name_plural = "Issue Views" + db_table = "issue_views" ordering = ("-created_at",) def __str__(self): """Return name of the View""" 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}>" diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py new file mode 100644 index 000000000..2baf7bdc1 --- /dev/null +++ b/apiserver/plane/utils/issue_filters.py @@ -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