diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index e01eb113b..97d722217 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -22,7 +22,7 @@ from .project import ( from .state import StateSerializer from .shortcut import ShortCutSerializer from .view import ViewSerializer -from .cycle import CycleSerializer, CycleIssueSerializer +from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer from .asset import FileAssetSerializer from .issue import ( IssueCreateSerializer, diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py index 09f35b669..d96a70d8c 100644 --- a/apiserver/plane/api/serializers/cycle.py +++ b/apiserver/plane/api/serializers/cycle.py @@ -5,12 +5,12 @@ from rest_framework import serializers from .base import BaseSerializer from .user import UserLiteSerializer from .issue import IssueStateSerializer -from plane.db.models import Cycle, CycleIssue +from plane.db.models import Cycle, CycleIssue, CycleFavorite class CycleSerializer(BaseSerializer): - owned_by = UserLiteSerializer(read_only=True) + is_favorite = serializers.BooleanField(read_only=True) class Meta: model = Cycle @@ -23,7 +23,6 @@ class CycleSerializer(BaseSerializer): class CycleIssueSerializer(BaseSerializer): - issue_detail = IssueStateSerializer(read_only=True, source="issue") sub_issues_count = serializers.IntegerField(read_only=True) @@ -35,3 +34,16 @@ class CycleIssueSerializer(BaseSerializer): "project", "cycle", ] + + +class CycleFavoriteSerializer(BaseSerializer): + cycle_detail = CycleSerializer(source="cycle", read_only=True) + + class Meta: + model = CycleFavorite + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "user", + ] diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 5e5db9e1a..b5e991088 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -84,6 +84,7 @@ from plane.api.views import ( CycleDateCheckEndpoint, CurrentUpcomingCyclesEndpoint, CompletedCyclesEndpoint, + CycleFavoriteViewSet, DraftCyclesEndpoint, ## End Cycles # Modules @@ -536,6 +537,25 @@ urlpatterns = [ DraftCyclesEndpoint.as_view(), name="project-cycle-draft", ), + path( + "workspaces//projects//user-favorite-cycles/", + CycleFavoriteViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="user-favorite-cycle", + ), + path( + "workspaces//projects//user-favorite-cycles//", + CycleFavoriteViewSet.as_view( + { + "delete": "destroy", + } + ), + name="user-favorite-cycle", + ), ## End Cycles # Issue path( diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 6186c3795..480c3b21b 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -46,6 +46,7 @@ from .cycle import ( CycleDateCheckEndpoint, CurrentUpcomingCyclesEndpoint, CompletedCyclesEndpoint, + CycleFavoriteViewSet, DraftCyclesEndpoint, ) from .asset import FileAssetEndpoint diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index d3dec7588..9a1f40a6d 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -2,7 +2,8 @@ import json # Django imports -from django.db.models import OuterRef, Func, F, Q +from django.db import IntegrityError +from django.db.models import OuterRef, Func, F, Q, Exists, OuterRef from django.core import serializers from django.utils import timezone @@ -13,9 +14,13 @@ from sentry_sdk import capture_exception # Module imports from . import BaseViewSet, BaseAPIView -from plane.api.serializers import CycleSerializer, CycleIssueSerializer +from plane.api.serializers import ( + CycleSerializer, + CycleIssueSerializer, + CycleFavoriteSerializer, +) from plane.api.permissions import ProjectEntityPermission -from plane.db.models import Cycle, CycleIssue, Issue +from plane.db.models import Cycle, CycleIssue, Issue, CycleFavorite from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.grouper import group_results @@ -45,6 +50,23 @@ class CycleViewSet(BaseViewSet): .distinct() ) + def list(self, request, slug, project_id): + try: + subquery = CycleFavorite.objects.filter( + user=self.request.user, + cycle_id=OuterRef("pk"), + project_id=project_id, + workspace__slug=slug, + ) + cycles = self.get_queryset().annotate(is_favorite=Exists(subquery)) + return Response(CycleSerializer(cycles, 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, + ) + def create(self, request, slug, project_id): try: if ( @@ -274,18 +296,24 @@ class CycleDateCheckEndpoint(BaseAPIView): class CurrentUpcomingCyclesEndpoint(BaseAPIView): def get(self, request, slug, project_id): try: + subquery = CycleFavorite.objects.filter( + user=self.request.user, + cycle_id=OuterRef("pk"), + project_id=project_id, + workspace__slug=slug, + ) current_cycle = Cycle.objects.filter( workspace__slug=slug, project_id=project_id, start_date__lte=timezone.now(), end_date__gte=timezone.now(), - ) + ).annotate(is_favorite=Exists(subquery)) upcoming_cycle = Cycle.objects.filter( workspace__slug=slug, project_id=project_id, start_date__gt=timezone.now(), - ) + ).annotate(is_favorite=Exists(subquery)) return Response( { @@ -306,11 +334,17 @@ class CurrentUpcomingCyclesEndpoint(BaseAPIView): class CompletedCyclesEndpoint(BaseAPIView): def get(self, request, slug, project_id): try: + subquery = CycleFavorite.objects.filter( + user=self.request.user, + cycle_id=OuterRef("pk"), + project_id=project_id, + workspace__slug=slug, + ) completed_cycles = Cycle.objects.filter( workspace__slug=slug, project_id=project_id, end_date__lt=timezone.now(), - ) + ).annotate(is_favorite=Exists(subquery)) return Response( { @@ -349,3 +383,65 @@ class DraftCyclesEndpoint(BaseAPIView): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class CycleFavoriteViewSet(BaseViewSet): + serializer_class = CycleFavoriteSerializer + model = CycleFavorite + + 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("cycle", "cycle__owned_by") + ) + + def create(self, request, slug, project_id): + try: + serializer = CycleFavoriteSerializer(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 cycle 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, cycle_id): + try: + cycle_favorite = CycleFavorite.objects.get( + project=project_id, + user=request.user, + workspace__slug=slug, + cycle_id=cycle_id, + ) + cycle_favorite.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + except CycleFavorite.DoesNotExist: + return Response( + {"error": "Cycle 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 c113ed7b6..fe1f70c9d 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -39,7 +39,7 @@ from .social_connection import SocialLoginConnection from .state import State -from .cycle import Cycle, CycleIssue +from .cycle import Cycle, CycleIssue, CycleFavorite from .shortcut import Shortcut diff --git a/apiserver/plane/db/models/cycle.py b/apiserver/plane/db/models/cycle.py index cb9308c95..6ecd3d3b0 100644 --- a/apiserver/plane/db/models/cycle.py +++ b/apiserver/plane/db/models/cycle.py @@ -7,7 +7,6 @@ from . import ProjectBaseModel class Cycle(ProjectBaseModel): - name = models.CharField(max_length=255, verbose_name="Cycle Name") description = models.TextField(verbose_name="Cycle Description", blank=True) start_date = models.DateField(verbose_name="Start Date", blank=True, null=True) @@ -18,7 +17,6 @@ class Cycle(ProjectBaseModel): related_name="owned_by_cycle", ) - class Meta: verbose_name = "Cycle" verbose_name_plural = "Cycles" @@ -50,3 +48,29 @@ class CycleIssue(ProjectBaseModel): def __str__(self): return f"{self.cycle}" + + +class CycleFavorite(ProjectBaseModel): + """_summary_ + CycleFavorite (model): To store all the cycle favorite of the user + """ + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="cycle_favorites", + ) + cycle = models.ForeignKey( + "db.Cycle", on_delete=models.CASCADE, related_name="cycle_favorites" + ) + + class Meta: + unique_together = ["cycle", "user"] + verbose_name = "Cycle Favorite" + verbose_name_plural = "Cycle Favorites" + db_table = "cycle_favorites" + ordering = ("-created_at",) + + def __str__(self): + """Return user and the cycle""" + return f"{self.user.email} <{self.cycle.name}>"