diff --git a/.env.example b/.env.example index fc1aef49d..2b6931761 100644 --- a/.env.example +++ b/.env.example @@ -9,11 +9,11 @@ NEXT_PUBLIC_GITHUB_ID="" NEXT_PUBLIC_GITHUB_APP_NAME="" # Sentry DSN for error monitoring NEXT_PUBLIC_SENTRY_DSN="" -# Enable/Disable OAUTH - default 0 for selfhosted instance +# Enable/Disable OAUTH - default 0 for selfhosted instance NEXT_PUBLIC_ENABLE_OAUTH=0 # Enable/Disable sentry NEXT_PUBLIC_ENABLE_SENTRY=0 -# Enable/Disable session recording +# Enable/Disable session recording NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0 # Enable/Disable event tracking NEXT_PUBLIC_TRACK_EVENTS=0 @@ -59,15 +59,16 @@ AWS_S3_BUCKET_NAME="uploads" FILE_SIZE_LIMIT=5242880 # GPT settings -OPENAI_API_KEY="" -GPT_ENGINE="" +OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint +OPENAI_API_KEY="sk-" # add your openai key here +GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access # Github GITHUB_CLIENT_SECRET="" # For fetching release notes # Settings related to Docker DOCKERIZED=1 -# set to 1 If using the pre-configured minio setup +# set to 1 If using the pre-configured minio setup USE_MINIO=1 # Nginx Configuration @@ -79,4 +80,4 @@ DEFAULT_PASSWORD="password123" # SignUps ENABLE_SIGNUP="1" -# Auto generated and Required that will be generated from setup.sh \ No newline at end of file +# Auto generated and Required that will be generated from setup.sh diff --git a/apiserver/Procfile b/apiserver/Procfile index 30d734913..694c49df4 100644 --- a/apiserver/Procfile +++ b/apiserver/Procfile @@ -1,2 +1,3 @@ web: gunicorn -w 4 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:$PORT --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile - -worker: celery -A plane worker -l info \ No newline at end of file +worker: celery -A plane worker -l info +beat: celery -A plane beat -l INFO \ No newline at end of file diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 2b72c5ae1..2ff210f98 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -21,6 +21,7 @@ from .project import ( ProjectIdentifierSerializer, ProjectFavoriteSerializer, ProjectLiteSerializer, + ProjectMemberLiteSerializer, ) from .state import StateSerializer, StateLiteSerializer from .view import IssueViewSerializer, IssueViewFavoriteSerializer @@ -41,6 +42,7 @@ from .issue import ( IssueLinkSerializer, IssueLiteSerializer, IssueAttachmentSerializer, + IssueSubscriberSerializer, ) from .module import ( @@ -74,4 +76,7 @@ from .estimate import ( ) from .inbox import InboxSerializer, InboxIssueSerializer, IssueStateInboxSerializer + from .analytic import AnalyticViewSerializer + +from .notification import NotificationSerializer diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 14782dbe5..7376cf0ff 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -19,6 +19,7 @@ from plane.db.models import ( IssueProperty, IssueBlocker, IssueAssignee, + IssueSubscriber, IssueLabel, Label, IssueBlocker, @@ -530,3 +531,14 @@ class IssueLiteSerializer(BaseSerializer): "created_at", "updated_at", ] + + +class IssueSubscriberSerializer(BaseSerializer): + class Meta: + model = IssueSubscriber + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "issue", + ] diff --git a/apiserver/plane/api/serializers/notification.py b/apiserver/plane/api/serializers/notification.py new file mode 100644 index 000000000..529cb9f9c --- /dev/null +++ b/apiserver/plane/api/serializers/notification.py @@ -0,0 +1,10 @@ +# Module imports +from .base import BaseSerializer +from plane.db.models import Notification + +class NotificationSerializer(BaseSerializer): + + class Meta: + model = Notification + fields = "__all__" + diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py index ed369f20a..db6021433 100644 --- a/apiserver/plane/api/serializers/project.py +++ b/apiserver/plane/api/serializers/project.py @@ -134,3 +134,20 @@ class ProjectFavoriteSerializer(BaseSerializer): "workspace", "user", ] + + +class ProjectLiteSerializer(BaseSerializer): + class Meta: + model = Project + fields = ["id", "identifier", "name"] + read_only_fields = fields + + +class ProjectMemberLiteSerializer(BaseSerializer): + member = UserLiteSerializer(read_only=True) + is_subscribed = serializers.BooleanField(read_only=True) + + class Meta: + model = ProjectMember + fields = ["member", "id", "is_subscribed"] + read_only_fields = fields diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 806ebcd6f..dc5b0e1dc 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -22,6 +22,7 @@ from plane.api.views import ( # User UserEndpoint, UpdateUserOnBoardedEndpoint, + UpdateUserTourCompletedEndpoint, UserActivityEndpoint, ## End User # Workspaces @@ -76,6 +77,8 @@ from plane.api.views import ( IssueLinkViewSet, BulkCreateIssueLabelsEndpoint, IssueAttachmentEndpoint, + IssueArchiveViewSet, + IssueSubscriberViewSet, ## End Issues # States StateViewSet, @@ -148,6 +151,9 @@ from plane.api.views import ( ExportAnalyticsEndpoint, DefaultAnalyticsEndpoint, ## End Analytics + # Notification + NotificationViewSet, + ## End Notification ) @@ -197,7 +203,12 @@ urlpatterns = [ path( "users/me/onboard/", UpdateUserOnBoardedEndpoint.as_view(), - name="change-password", + name="user-onboard", + ), + path( + "users/me/tour-completed/", + UpdateUserTourCompletedEndpoint.as_view(), + name="user-tour", ), path("users/activities/", UserActivityEndpoint.as_view(), name="user-activities"), # user workspaces @@ -467,7 +478,6 @@ urlpatterns = [ "workspaces//user-favorite-projects/", ProjectFavoritesViewSet.as_view( { - "get": "list", "post": "create", } ), @@ -797,6 +807,34 @@ urlpatterns = [ name="project-issue-comment", ), ## End IssueComments + # Issue Subscribers + path( + "workspaces//projects//issues//issue-subscribers/", + IssueSubscriberViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-issue-subscribers", + ), + path( + "workspaces//projects//issues//issue-subscribers//", + IssueSubscriberViewSet.as_view({"delete": "destroy"}), + name="project-issue-subscribers", + ), + path( + "workspaces//projects//issues//subscribe/", + IssueSubscriberViewSet.as_view( + { + "get": "subscription_status", + "post": "subscribe", + "delete": "unsubscribe", + } + ), + name="project-issue-subscribers", + ), + ## End Issue Subscribers ## IssueProperty path( "workspaces//projects//issue-properties/", @@ -821,6 +859,36 @@ urlpatterns = [ name="project-issue-roadmap", ), ## IssueProperty Ebd + ## Issue Archives + path( + "workspaces//projects//archived-issues/", + IssueArchiveViewSet.as_view( + { + "get": "list", + } + ), + name="project-issue-archive", + ), + path( + "workspaces//projects//archived-issues//", + IssueArchiveViewSet.as_view( + { + "get": "retrieve", + "delete": "destroy", + } + ), + name="project-issue-archive", + ), + path( + "workspaces//projects//unarchive//", + IssueArchiveViewSet.as_view( + { + "post": "unarchive", + } + ), + name="project-issue-archive", + ), + ## End Issue Archives ## File Assets path( "workspaces//file-assets/", @@ -1273,4 +1341,46 @@ urlpatterns = [ name="default-analytics", ), ## End Analytics + # Notification + path( + "workspaces//users/notifications/", + NotificationViewSet.as_view( + { + "get": "list", + } + ), + name="notifications", + ), + path( + "workspaces//users/notifications//", + NotificationViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="notifications", + ), + path( + "workspaces//users/notifications//read/", + NotificationViewSet.as_view( + { + "post": "mark_read", + "delete": "mark_unread", + } + ), + name="notifications", + ), + path( + "workspaces//users/notifications//archive/", + NotificationViewSet.as_view( + { + "post": "archive", + "delete": "unarchive", + } + ), + name="notifications", + ), + ## End Notification ] diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index f8d170532..2f0a54c1d 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -16,6 +16,7 @@ from .project import ( from .people import ( UserEndpoint, UpdateUserOnBoardedEndpoint, + UpdateUserTourCompletedEndpoint, UserActivityEndpoint, ) @@ -65,6 +66,8 @@ from .issue import ( IssueLinkViewSet, BulkCreateIssueLabelsEndpoint, IssueAttachmentEndpoint, + IssueArchiveViewSet, + IssueSubscriberViewSet, ) from .auth_extended import ( @@ -133,6 +136,7 @@ from .estimate import ( from .release import ReleaseNotesEndpoint from .inbox import InboxViewSet, InboxIssueViewSet + from .analytic import ( AnalyticsEndpoint, AnalyticViewViewset, @@ -140,3 +144,5 @@ from .analytic import ( ExportAnalyticsEndpoint, DefaultAnalyticsEndpoint, ) + +from .notification import NotificationViewSet \ No newline at end of file diff --git a/apiserver/plane/api/views/authentication.py b/apiserver/plane/api/views/authentication.py index 068fae5a9..0d37b1c33 100644 --- a/apiserver/plane/api/views/authentication.py +++ b/apiserver/plane/api/views/authentication.py @@ -345,7 +345,7 @@ class MagicSignInEndpoint(BaseAPIView): def post(self, request): try: - user_token = request.data.get("token", "").strip().lower() + user_token = request.data.get("token", "").strip() key = request.data.get("key", False) if not key or user_token == "": diff --git a/apiserver/plane/api/views/gpt.py b/apiserver/plane/api/views/gpt.py index a48bea242..8878e99a5 100644 --- a/apiserver/plane/api/views/gpt.py +++ b/apiserver/plane/api/views/gpt.py @@ -67,7 +67,7 @@ class GPTIntegrationEndpoint(BaseAPIView): openai.api_key = settings.OPENAI_API_KEY response = openai.Completion.create( - engine=settings.GPT_ENGINE, + model=settings.GPT_ENGINE, prompt=final_text, temperature=0.7, max_tokens=1024, diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index bfefc91ba..1c2c95a96 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -15,6 +15,7 @@ from django.db.models import ( Value, CharField, When, + Exists, Max, ) from django.core.serializers.json import DjangoJSONEncoder @@ -43,11 +44,15 @@ from plane.api.serializers import ( IssueLinkSerializer, IssueLiteSerializer, IssueAttachmentSerializer, + IssueSubscriberSerializer, + ProjectMemberSerializer, + ProjectMemberLiteSerializer, ) from plane.api.permissions import ( ProjectEntityPermission, WorkSpaceAdminPermission, ProjectMemberPermission, + ProjectLitePermission, ) from plane.db.models import ( Project, @@ -59,6 +64,8 @@ from plane.db.models import ( IssueLink, IssueAttachment, State, + IssueSubscriber, + ProjectMember, ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.grouper import group_results @@ -905,3 +912,347 @@ class IssueAttachmentEndpoint(BaseAPIView): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class IssueArchiveViewSet(BaseViewSet): + permission_classes = [ + ProjectEntityPermission, + ] + serializer_class = IssueFlatSerializer + model = Issue + + def get_queryset(self): + return ( + Issue.objects.annotate( + sub_issues_count=Issue.objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .filter(archived_at__isnull=False) + .filter(project_id=self.kwargs.get("project_id")) + .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") + ) + + @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") + + # 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) + .annotate(cycle_id=F("issue_cycle__id")) + .annotate(module_id=F("issue_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) + + 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 + 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, + ) + + def retrieve(self, request, slug, project_id, pk=None): + try: + issue = Issue.objects.get( + workspace__slug=slug, + project_id=project_id, + archived_at__isnull=False, + pk=pk, + ) + 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 + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def unarchive(self, request, slug, project_id, pk=None): + try: + issue = Issue.objects.get( + workspace__slug=slug, + project_id=project_id, + archived_at__isnull=False, + pk=pk, + ) + issue.archived_at = None + issue.save() + issue_activity.delay( + type="issue.activity.updated", + requested_data=json.dumps({"archived_at": None}), + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project_id), + current_instance=None, + ) + + 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) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong, please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + +class IssueSubscriberViewSet(BaseViewSet): + serializer_class = IssueSubscriberSerializer + model = IssueSubscriber + + permission_classes = [ + ProjectEntityPermission, + ] + + def get_permissions(self): + if self.action in ["subscribe", "unsubscribe", "subscription_status"]: + self.permission_classes = [ + ProjectLitePermission, + ] + else: + self.permission_classes = [ + ProjectEntityPermission, + ] + + return super(IssueSubscriberViewSet, self).get_permissions() + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), + issue_id=self.kwargs.get("issue_id"), + ) + + 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 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"), + ) + ) + ).select_related("member") + serializer = ProjectMemberLiteSerializer(members, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) + return Response( + {"error": e}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def destroy(self, request, slug, project_id, issue_id, subscriber_id): + try: + issue_subscriber = IssueSubscriber.objects.get( + project=project_id, + subscriber=subscriber_id, + workspace__slug=slug, + issue=issue_id, + ) + issue_subscriber.delete() + return Response( + status=status.HTTP_204_NO_CONTENT, + ) + except IssueSubscriber.DoesNotExist: + return Response( + {"error": "User is not subscribed to this issue"}, + 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 subscribe(self, request, slug, project_id, issue_id): + try: + if IssueSubscriber.objects.filter( + issue_id=issue_id, + subscriber=request.user, + workspace__slug=slug, + project=project_id, + ).exists(): + return Response( + {"message": "User already subscribed to the issue."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + subscriber = IssueSubscriber.objects.create( + issue_id=issue_id, + subscriber_id=request.user.id, + project_id=project_id, + ) + serilaizer = IssueSubscriberSerializer(subscriber) + return Response(serilaizer.data, status=status.HTTP_201_CREATED) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong, please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def unsubscribe(self, request, slug, project_id, issue_id): + try: + issue_subscriber = IssueSubscriber.objects.get( + project=project_id, + subscriber=request.user, + workspace__slug=slug, + issue=issue_id, + ) + issue_subscriber.delete() + return Response( + status=status.HTTP_204_NO_CONTENT, + ) + except IssueSubscriber.DoesNotExist: + return Response( + {"error": "User subscribed to this issue"}, + 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 subscription_status(self, request, slug, project_id, issue_id): + try: + issue_subscriber = IssueSubscriber.objects.filter( + issue=issue_id, + subscriber=request.user, + workspace__slug=slug, + project=project_id, + ).exists() + return Response({"subscribed": issue_subscriber}, 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/api/views/notification.py b/apiserver/plane/api/views/notification.py new file mode 100644 index 000000000..ac0082430 --- /dev/null +++ b/apiserver/plane/api/views/notification.py @@ -0,0 +1,211 @@ +# Django imports +from django.db.models import Q +from django.utils import timezone + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from sentry_sdk import capture_exception + +# Module imports +from .base import BaseViewSet +from plane.db.models import Notification, IssueAssignee, IssueSubscriber, Issue +from plane.api.serializers import NotificationSerializer + + +class NotificationViewSet(BaseViewSet): + model = Notification + serializer_class = NotificationSerializer + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter( + workspace__slug=self.kwargs.get("slug"), + receiver_id=self.request.user.id, + ) + .select_related("workspace") + ) + + def list(self, request, slug): + try: + order_by = request.GET.get("order_by", "-created_at") + snoozed = request.GET.get("snoozed", "false") + archived = request.GET.get("archived", "false") + read = request.GET.get("read", "false") + + # Filter type + type = request.GET.get("type", "all") + + notifications = Notification.objects.filter( + workspace__slug=slug, receiver_id=request.user.id + ).order_by(order_by) + + # Filter for snoozed notifications + if snoozed == "false": + notifications = notifications.filter( + Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), + ) + + if snoozed == "true": + notifications = notifications.filter( + Q(snoozed_till__lt=timezone.now()) | Q(snoozed_till__isnull=False) + ) + + if read == "true": + notifications = notifications.filter(read_at__isnull=False) + if read == "false": + notifications = notifications.filter(read_at__isnull=True) + + # Filter for archived or unarchive + if archived == "false": + notifications = notifications.filter(archived_at__isnull=True) + + if archived == "true": + notifications = notifications.filter(archived_at__isnull=False) + + # Subscribed issues + if type == "watching": + issue_ids = IssueSubscriber.objects.filter( + workspace__slug=slug, subscriber_id=request.user.id + ).values_list("issue_id", flat=True) + notifications = notifications.filter(entity_identifier__in=issue_ids) + + # Assigned Issues + if type == "assigned": + issue_ids = IssueAssignee.objects.filter( + workspace__slug=slug, assignee_id=request.user.id + ).values_list("issue_id", flat=True) + notifications = notifications.filter(entity_identifier__in=issue_ids) + + # 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) + + serializer = NotificationSerializer(notifications, many=True) + return Response(serializer.data, 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, + ) + + def partial_update(self, request, slug, pk): + try: + notification = Notification.objects.get( + workspace__slug=slug, pk=pk, receiver=request.user + ) + # Only read_at and snoozed_till can be updated + notification_data = { + "snoozed_till": request.data.get("snoozed_till", None), + } + serializer = NotificationSerializer( + notification, data=notification_data, partial=True + ) + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except Notification.DoesNotExist: + return Response( + {"error": "Notification does not exists"}, + 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 mark_read(self, request, slug, pk): + try: + notification = Notification.objects.get( + receiver=request.user, workspace__slug=slug, pk=pk + ) + notification.read_at = timezone.now() + notification.save() + serializer = NotificationSerializer(notification) + return Response(serializer.data, status=status.HTTP_200_OK) + except Notification.DoesNotExist: + return Response( + {"error": "Notification does not exists"}, + 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 mark_unread(self, request, slug, pk): + try: + notification = Notification.objects.get( + receiver=request.user, workspace__slug=slug, pk=pk + ) + notification.read_at = None + notification.save() + serializer = NotificationSerializer(notification) + return Response(serializer.data, status=status.HTTP_200_OK) + except Notification.DoesNotExist: + return Response( + {"error": "Notification does not exists"}, + 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 archive(self, request, slug, pk): + try: + notification = Notification.objects.get( + receiver=request.user, workspace__slug=slug, pk=pk + ) + notification.archived_at = timezone.now() + notification.save() + serializer = NotificationSerializer(notification) + return Response(serializer.data, status=status.HTTP_200_OK) + except Notification.DoesNotExist: + return Response( + {"error": "Notification does not exists"}, + 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 unarchive(self, request, slug, pk): + try: + notification = Notification.objects.get( + receiver=request.user, workspace__slug=slug, pk=pk + ) + notification.archived_at = None + notification.save() + serializer = NotificationSerializer(notification) + return Response(serializer.data, status=status.HTTP_200_OK) + except Notification.DoesNotExist: + return Response( + {"error": "Notification does not exists"}, + 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/people.py b/apiserver/plane/api/views/people.py index 8e19fea1a..705f5c96e 100644 --- a/apiserver/plane/api/views/people.py +++ b/apiserver/plane/api/views/people.py @@ -37,7 +37,9 @@ class UserEndpoint(BaseViewSet): workspace_invites = WorkspaceMemberInvite.objects.filter( email=request.user.email ).count() - assigned_issues = Issue.issue_objects.filter(assignees__in=[request.user]).count() + assigned_issues = Issue.issue_objects.filter( + assignees__in=[request.user] + ).count() serialized_data = UserSerializer(request.user).data serialized_data["workspace"] = { @@ -47,7 +49,9 @@ class UserEndpoint(BaseViewSet): "fallback_workspace_slug": workspace.slug, "invites": workspace_invites, } - serialized_data.setdefault("issues", {})["assigned_issues"] = assigned_issues + serialized_data.setdefault("issues", {})[ + "assigned_issues" + ] = assigned_issues return Response( serialized_data, @@ -59,11 +63,15 @@ class UserEndpoint(BaseViewSet): workspace_invites = WorkspaceMemberInvite.objects.filter( email=request.user.email ).count() - assigned_issues = Issue.issue_objects.filter(assignees__in=[request.user]).count() + assigned_issues = Issue.issue_objects.filter( + assignees__in=[request.user] + ).count() - fallback_workspace = Workspace.objects.filter( - workspace_member__member=request.user - ).order_by("created_at").first() + fallback_workspace = ( + Workspace.objects.filter(workspace_member__member=request.user) + .order_by("created_at") + .first() + ) serialized_data = UserSerializer(request.user).data @@ -78,7 +86,9 @@ class UserEndpoint(BaseViewSet): else None, "invites": workspace_invites, } - serialized_data.setdefault("issues", {})["assigned_issues"] = assigned_issues + serialized_data.setdefault("issues", {})[ + "assigned_issues" + ] = assigned_issues return Response( serialized_data, @@ -109,6 +119,23 @@ class UpdateUserOnBoardedEndpoint(BaseAPIView): ) +class UpdateUserTourCompletedEndpoint(BaseAPIView): + def patch(self, request): + try: + user = User.objects.get(pk=request.user.id) + user.is_tour_completed = request.data.get("is_tour_completed", False) + user.save() + return Response( + {"message": "Updated successfully"}, 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 UserActivityEndpoint(BaseAPIView, BasePaginator): def get(self, request): try: diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 822dc78b5..357f94e10 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -96,6 +96,7 @@ class ProjectViewSet(BaseViewSet): def list(self, request, slug): try: + is_favorite = request.GET.get("is_favorite", "all") subquery = ProjectFavorite.objects.filter( user=self.request.user, project_id=OuterRef("pk"), @@ -126,6 +127,12 @@ class ProjectViewSet(BaseViewSet): .values("count") ) ) + + if is_favorite == "true": + projects = projects.filter(is_favorite=True) + if is_favorite == "false": + projects = projects.filter(is_favorite=False) + return Response(ProjectDetailSerializer(projects, many=True).data) except Exception as e: capture_exception(e) diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 5865a5982..28b25b00b 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -5,6 +5,7 @@ import requests # Django imports from django.conf import settings from django.core.serializers.json import DjangoJSONEncoder +from django.utils import timezone # Third Party imports from celery import shared_task @@ -20,6 +21,9 @@ from plane.db.models import ( State, Cycle, Module, + IssueSubscriber, + Notification, + IssueAssignee, ) from plane.api.serializers import IssueActivitySerializer @@ -554,6 +558,64 @@ def track_estimate_points( ) +def track_archive_at( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + if requested_data.get("archived_at") is None: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"{actor.email} has restored the issue", + verb="updated", + actor=actor, + field="archived_at", + old_value="archive", + new_value="restore", + ) + ) + else: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"Plane has archived the issue", + verb="updated", + actor=actor, + field="archived_at", + old_value=None, + new_value="archive", + ) + ) + + +def track_closed_to( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + if requested_data.get("closed_to") is not None: + updated_state = State.objects.get( + pk=requested_data.get("closed_to"), project=project + ) + + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=None, + new_value=updated_state.name, + field="state", + project=project, + workspace=project.workspace, + comment=f"Plane updated the state to {updated_state.name}", + old_identifier=None, + new_identifier=updated_state.id, + ) + ) + + def update_issue_activity( requested_data, current_instance, issue_id, project, actor, issue_activities ): @@ -570,6 +632,8 @@ def update_issue_activity( "blocks_list": track_blocks, "blockers_list": track_blockings, "estimate_point": track_estimate_points, + "archived_at": track_archive_at, + "closed_to": track_closed_to, } requested_data = json.loads(requested_data) if requested_data is not None else None @@ -950,7 +1014,13 @@ def delete_attachment_activity( # Receive message from room group @shared_task def issue_activity( - type, requested_data, current_instance, issue_id, actor_id, project_id + type, + requested_data, + current_instance, + issue_id, + actor_id, + project_id, + subscriber=True, ): try: issue_activities = [] @@ -958,6 +1028,20 @@ def issue_activity( actor = User.objects.get(pk=actor_id) project = Project.objects.get(pk=project_id) + issue = Issue.objects.filter(pk=issue_id).first() + if issue is not None: + issue.updated_at = timezone.now() + issue.save() + + if subscriber: + # add the user to issue subscriber + try: + _ = IssueSubscriber.objects.get_or_create( + issue_id=issue_id, subscriber=actor + ) + except Exception as e: + pass + ACTIVITY_MAPPER = { "issue.activity.created": create_issue_activity, "issue.activity.updated": update_issue_activity, @@ -992,18 +1076,72 @@ def issue_activity( # Post the updates to segway for integrations and webhooks if len(issue_activities_created): # Don't send activities if the actor is a bot - if settings.PROXY_BASE_URL: - for issue_activity in issue_activities_created: - headers = {"Content-Type": "application/json"} - issue_activity_json = json.dumps( - IssueActivitySerializer(issue_activity).data, - cls=DjangoJSONEncoder, - ) - _ = requests.post( - f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(issue_activity.workspace_id)}/projects/{str(issue_activity.project_id)}/issues/{str(issue_activity.issue_id)}/issue-activity-hooks/", - json=issue_activity_json, - headers=headers, + try: + if settings.PROXY_BASE_URL: + for issue_activity in issue_activities_created: + headers = {"Content-Type": "application/json"} + issue_activity_json = json.dumps( + IssueActivitySerializer(issue_activity).data, + cls=DjangoJSONEncoder, + ) + _ = requests.post( + f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(issue_activity.workspace_id)}/projects/{str(issue_activity.project_id)}/issues/{str(issue_activity.issue_id)}/issue-activity-hooks/", + json=issue_activity_json, + headers=headers, + ) + except Exception as e: + capture_exception(e) + + # Create Notifications + bulk_notifications = [] + + issue_subscribers = list( + IssueSubscriber.objects.filter(project=project, issue_id=issue_id) + .exclude(subscriber_id=actor_id) + .values_list("subscriber", flat=True) + ) + + issue_assignees = list( + IssueAssignee.objects.filter(project=project, issue_id=issue_id) + .exclude(assignee_id=actor_id) + .values_list("assignee", flat=True) + ) + + issue_subscribers = issue_subscribers + issue_assignees + + if issue.created_by_id: + issue_subscribers = issue_subscribers + [issue.created_by_id] + + issue = Issue.objects.get(project=project, pk=issue_id) + for subscriber in issue_subscribers: + for issue_activity in issue_activities_created: + bulk_notifications.append( + Notification( + workspace=project.workspace, + sender="in_app:issue_activities", + triggered_by_id=actor_id, + receiver_id=subscriber, + entity_identifier=issue_id, + entity_name="issue", + project=project, + title=issue_activity.comment, + data={ + "issue": { + "id": str(issue_id), + "name": str(issue.name), + "identifier": str(project.identifier), + "sequence_id": issue.sequence_id, + "state_name": issue.state.name, + "state_group": issue.state.group, + }, + "issue_activity": str(issue_activity.id), + }, ) + ) + + # Bulk create notifications + Notification.objects.bulk_create(bulk_notifications, batch_size=100) + return except Exception as e: # Print logs if in DEBUG mode diff --git a/apiserver/plane/bgtasks/issue_automation_task.py b/apiserver/plane/bgtasks/issue_automation_task.py new file mode 100644 index 000000000..d051b5118 --- /dev/null +++ b/apiserver/plane/bgtasks/issue_automation_task.py @@ -0,0 +1,147 @@ +# Python imports +import json +from datetime import timedelta + +# Django imports +from django.utils import timezone +from django.db.models import Q +from django.conf import settings + +# Third party imports +from celery import shared_task +from sentry_sdk import capture_exception + +# Module imports +from plane.db.models import Issue, Project, State +from plane.bgtasks.issue_activites_task import issue_activity + + +@shared_task +def archive_and_close_old_issues(): + archive_old_issues() + close_old_issues() + + +def archive_old_issues(): + try: + # Get all the projects whose archive_in is greater than 0 + projects = Project.objects.filter(archive_in__gt=0) + + for project in projects: + project_id = project.id + archive_in = project.archive_in + + # Get all the issues whose updated_at in less that the archive_in month + issues = Issue.objects.filter( + Q( + project=project_id, + archived_at__isnull=True, + updated_at__lte=(timezone.now() - timedelta(days=archive_in * 30)), + state__group__in=["completed", "cancelled"], + ), + Q(issue_cycle__isnull=True) + | ( + Q(issue_cycle__cycle__end_date__lt=timezone.now().date()) + & Q(issue_cycle__isnull=False) + ), + Q(issue_module__isnull=True) + | ( + Q(issue_module__module__target_date__lt=timezone.now().date()) + & Q(issue_module__isnull=False) + ), + ) + + # Check if Issues + if issues: + issues_to_update = [] + for issue in issues: + issue.archived_at = timezone.now() + issues_to_update.append(issue) + + # Bulk Update the issues and log the activity + Issue.objects.bulk_update( + issues_to_update, ["archived_at"], batch_size=100 + ) + [ + issue_activity.delay( + type="issue.activity.updated", + requested_data=json.dumps({"archived_at": issue.archived_at}), + actor_id=str(project.created_by_id), + issue_id=issue.id, + project_id=project_id, + current_instance=None, + subscriber=False, + ) + for issue in issues_to_update + ] + return + except Exception as e: + if settings.DEBUG: + print(e) + capture_exception(e) + return + + +def close_old_issues(): + try: + # Get all the projects whose close_in is greater than 0 + projects = Project.objects.filter(close_in__gt=0).select_related( + "default_state" + ) + + for project in projects: + project_id = project.id + close_in = project.close_in + + # Get all the issues whose updated_at in less that the close_in month + issues = Issue.objects.filter( + Q( + project=project_id, + archived_at__isnull=True, + updated_at__lte=(timezone.now() - timedelta(days=close_in * 30)), + state__group__in=["backlog", "unstarted", "started"], + ), + Q(issue_cycle__isnull=True) + | ( + Q(issue_cycle__cycle__end_date__lt=timezone.now().date()) + & Q(issue_cycle__isnull=False) + ), + Q(issue_module__isnull=True) + | ( + Q(issue_module__module__target_date__lt=timezone.now().date()) + & Q(issue_module__isnull=False) + ), + ) + + # Check if Issues + if issues: + if project.default_state is None: + close_state = State.objects.filter(group="cancelled").first() + else: + close_state = project.default_state + + issues_to_update = [] + for issue in issues: + issue.state = close_state + issues_to_update.append(issue) + + # Bulk Update the issues and log the activity + Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100) + [ + issue_activity.delay( + type="issue.activity.updated", + requested_data=json.dumps({"closed_to": issue.state_id}), + actor_id=str(project.created_by_id), + issue_id=issue.id, + project_id=project_id, + current_instance=None, + subscriber=False, + ) + for issue in issues_to_update + ] + return + except Exception as e: + if settings.DEBUG: + print(e) + capture_exception(e) + return diff --git a/apiserver/plane/celery.py b/apiserver/plane/celery.py index 1fbbdd732..ed0dc419e 100644 --- a/apiserver/plane/celery.py +++ b/apiserver/plane/celery.py @@ -1,6 +1,7 @@ import os from celery import Celery from plane.settings.redis import redis_instance +from celery.schedules import crontab # Set the default Django settings module for the 'celery' program. os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production") @@ -13,5 +14,15 @@ app = Celery("plane") # pickle the object when using Windows. app.config_from_object("django.conf:settings", namespace="CELERY") +app.conf.beat_schedule = { + # Executes every day at 12 AM + "check-every-day-to-archive-and-close": { + "task": "plane.bgtasks.issue_automation_task.archive_and_close_old_issues", + "schedule": crontab(hour=0, minute=0), + }, +} + # Load task modules from all registered Django app configs. app.autodiscover_tasks() + +app.conf.beat_scheduler = 'django_celery_beat.schedulers.DatabaseScheduler' \ No newline at end of file diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 96c649a83..1c075478d 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -33,6 +33,7 @@ from .issue import ( IssueLink, IssueSequence, IssueAttachment, + IssueSubscriber, ) from .asset import FileAsset @@ -66,4 +67,7 @@ from .page import Page, PageBlock, PageFavorite, PageLabel from .estimate import Estimate, EstimatePoint from .inbox import Inbox, InboxIssue + from .analytic import AnalyticView + +from .notification import Notification \ No newline at end of file diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 1ecad6424..f301d4191 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -28,6 +28,8 @@ class IssueManager(models.Manager): | models.Q(issue_inbox__status=2) | models.Q(issue_inbox__isnull=True) ) + .filter(archived_at__isnull=True) + .exclude(archived_at__isnull=False) ) @@ -81,6 +83,7 @@ class Issue(ProjectBaseModel): ) sort_order = models.FloatField(default=65535) completed_at = models.DateTimeField(null=True) + archived_at = models.DateField(null=True) objects = models.Manager() issue_objects = IssueManager() @@ -401,6 +404,27 @@ class IssueSequence(ProjectBaseModel): ordering = ("-created_at",) +class IssueSubscriber(ProjectBaseModel): + issue = models.ForeignKey( + Issue, on_delete=models.CASCADE, related_name="issue_subscribers" + ) + subscriber = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="issue_subscribers", + ) + + class Meta: + unique_together = ["issue", "subscriber"] + verbose_name = "Issue Subscriber" + verbose_name_plural = "Issue Subscribers" + db_table = "issue_subscribers" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.subscriber.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/notification.py b/apiserver/plane/db/models/notification.py new file mode 100644 index 000000000..3df935718 --- /dev/null +++ b/apiserver/plane/db/models/notification.py @@ -0,0 +1,37 @@ +# Django imports +from django.db import models + +# Third party imports +from .base import BaseModel + + +class Notification(BaseModel): + workspace = models.ForeignKey( + "db.Workspace", related_name="notifications", on_delete=models.CASCADE + ) + project = models.ForeignKey( + "db.Project", related_name="notifications", on_delete=models.CASCADE, null=True + ) + data = models.JSONField(null=True) + entity_identifier = models.UUIDField(null=True) + entity_name = models.CharField(max_length=255) + title = models.TextField() + message = models.JSONField(null=True) + message_html = models.TextField(blank=True, default="

") + message_stripped = models.TextField(blank=True, null=True) + sender = models.CharField(max_length=255) + triggered_by = models.ForeignKey("db.User", related_name="triggered_notifications", on_delete=models.SET_NULL, null=True) + receiver = models.ForeignKey("db.User", related_name="received_notifications", on_delete=models.CASCADE) + read_at = models.DateTimeField(null=True) + snoozed_till = models.DateTimeField(null=True) + archived_at = models.DateTimeField(null=True) + + class Meta: + verbose_name = "Notification" + verbose_name_plural = "Notifications" + db_table = "notifications" + ordering = ("-created_at",) + + def __str__(self): + """Return name of the notifications""" + return f"{self.receiver.email} <{self.workspace.name}>" diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index 0b6c4b50d..b28cbc69e 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -4,6 +4,7 @@ from django.conf import settings from django.template.defaultfilters import slugify from django.db.models.signals import post_save from django.dispatch import receiver +from django.core.validators import MinValueValidator, MaxValueValidator # Modeule imports from plane.db.mixins import AuditModel @@ -74,6 +75,15 @@ class Project(BaseModel): estimate = models.ForeignKey( "db.Estimate", on_delete=models.SET_NULL, related_name="projects", null=True ) + archive_in = models.IntegerField( + default=0, validators=[MinValueValidator(0), MaxValueValidator(12)] + ) + close_in = models.IntegerField( + default=0, validators=[MinValueValidator(0), MaxValueValidator(12)] + ) + default_state = models.ForeignKey( + "db.State", on_delete=models.SET_NULL, null=True, related_name="default_state" + ) def __str__(self): """Return name of the project""" diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index b0ab72159..36b3a1f6b 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -18,6 +18,13 @@ from sentry_sdk import capture_exception from slack_sdk import WebClient from slack_sdk.errors import SlackApiError +def get_default_onboarding(): + return { + "profile_complete": False, + "workspace_create": False, + "workspace_invite": False, + "workspace_join": False, + } class User(AbstractBaseUser, PermissionsMixin): id = models.UUIDField( @@ -73,6 +80,8 @@ class User(AbstractBaseUser, PermissionsMixin): role = models.CharField(max_length=300, null=True, blank=True) is_bot = models.BooleanField(default=False) theme = models.JSONField(default=dict) + is_tour_completed = models.BooleanField(default=False) + onboarding_step = models.JSONField(default=get_default_onboarding) USERNAME_FIELD = "email" diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 2e0266159..e3a918c18 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -35,6 +35,7 @@ INSTALLED_APPS = [ "rest_framework_simplejwt.token_blacklist", "corsheaders", "taggit", + "django_celery_beat", ] MIDDLEWARE = [ @@ -213,3 +214,4 @@ SIMPLE_JWT = { CELERY_TIMEZONE = TIME_ZONE CELERY_TASK_SERIALIZER = 'json' CELERY_ACCEPT_CONTENT = ['application/json'] +CELERY_IMPORTS = ("plane.bgtasks.issue_automation_task",) diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py index e6f5f8e39..194b2629f 100644 --- a/apiserver/plane/settings/local.py +++ b/apiserver/plane/settings/local.py @@ -10,9 +10,7 @@ from sentry_sdk.integrations.redis import RedisIntegration from .common import * # noqa -DEBUG = int(os.environ.get( - "DEBUG", 1 -)) == 1 +DEBUG = int(os.environ.get("DEBUG", 1)) == 1 EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" @@ -27,13 +25,11 @@ DATABASES = { } } -DOCKERIZED = int(os.environ.get( - "DOCKERIZED", 0 -)) == 1 +DOCKERIZED = int(os.environ.get("DOCKERIZED", 0)) == 1 USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1 -FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) +FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) if DOCKERIZED: DATABASES["default"] = dj_database_url.config() @@ -65,6 +61,27 @@ if os.environ.get("SENTRY_DSN", False): traces_sample_rate=0.7, profiles_sample_rate=1.0, ) +else: + LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + }, + }, + "root": { + "handlers": ["console"], + "level": "DEBUG", + }, + "loggers": { + "*": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": True, + }, + }, + } REDIS_HOST = "localhost" REDIS_PORT = 6379 @@ -83,8 +100,9 @@ PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False) ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False) ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False) +OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1") OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False) -GPT_ENGINE = os.environ.get("GPT_ENGINE", "text-davinci-003") +GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo") SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False) @@ -95,4 +113,4 @@ CELERY_BROKER_URL = os.environ.get("REDIS_URL") GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) -ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1" \ No newline at end of file +ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1" diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index 2e40c5998..98e22bcbd 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -246,8 +246,9 @@ PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False) ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False) ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False) +OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1") OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False) -GPT_ENGINE = os.environ.get("GPT_ENGINE", "text-davinci-003") +GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo") SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False) diff --git a/apiserver/plane/settings/staging.py b/apiserver/plane/settings/staging.py index 076bb3e3c..daf8f974b 100644 --- a/apiserver/plane/settings/staging.py +++ b/apiserver/plane/settings/staging.py @@ -11,10 +11,9 @@ from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.redis import RedisIntegration from .common import * # noqa + # Database -DEBUG = int(os.environ.get( - "DEBUG", 1 -)) == 1 +DEBUG = int(os.environ.get("DEBUG", 1)) == 1 DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql_psycopg2", @@ -56,9 +55,7 @@ STORAGES = { # Make true if running in a docker environment -DOCKERIZED = int(os.environ.get( - "DOCKERIZED", 0 -)) == 1 +DOCKERIZED = int(os.environ.get("DOCKERIZED", 0)) == 1 FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1 @@ -201,15 +198,19 @@ PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False) ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False) ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False) + +OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1") OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False) -GPT_ENGINE = os.environ.get("GPT_ENGINE", "text-davinci-003") +GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo") SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False) LOGGER_BASE_URL = os.environ.get("LOGGER_BASE_URL", False) redis_url = os.environ.get("REDIS_URL") -broker_url = f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}" +broker_url = ( + f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}" +) CELERY_RESULT_BACKEND = broker_url CELERY_BROKER_URL = broker_url diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 537564828..1eff57555 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -3,7 +3,7 @@ Django==4.2.3 django-braces==1.15.0 django-taggit==4.0.0 -psycopg2==2.9.6 +psycopg==3.1.9 django-oauth-toolkit==2.3.0 mistune==3.0.1 djangorestframework==3.14.0 @@ -28,4 +28,5 @@ uvicorn==0.22.0 channels==4.0.0 openai==0.27.8 slack-sdk==3.21.3 -celery==5.3.1 \ No newline at end of file +celery==5.3.1 +django_celery_beat==2.5.0 diff --git a/apps/app/components/analytics/custom-analytics/custom-analytics.tsx b/apps/app/components/analytics/custom-analytics/custom-analytics.tsx index 4d6a2bdbe..d04733b56 100644 --- a/apps/app/components/analytics/custom-analytics/custom-analytics.tsx +++ b/apps/app/components/analytics/custom-analytics/custom-analytics.tsx @@ -61,7 +61,7 @@ export const CustomAnalytics: React.FC = ({ = ({ }; const selectedProjects = - params.project && params.project.length > 0 ? params.project : projects.map((p) => p.id); + params.project && params.project.length > 0 ? params.project : projects?.map((p) => p.id); return (
= ({
)} -
+
{fullScreen ? ( <> {!isProjectLevel && selectedProjects && selectedProjects.length > 0 && ( @@ -215,61 +215,62 @@ export const AnalyticsSidebar: React.FC = ({

Selected Projects

{selectedProjects.map((projectId) => { - const project: IProject = projects.find((p) => p.id === projectId); + const project = projects?.find((p) => p.id === projectId); - return ( -
-
- {project.emoji ? ( - - {renderEmoji(project.emoji)} - - ) : project.icon_prop ? ( -
- - {project.icon_prop.name} + if (project) + return ( +
+
+ {project.emoji ? ( + + {renderEmoji(project.emoji)} -
- ) : ( - - {project?.name.charAt(0)} - - )} -
- {project.name} - - ({project.identifier}) - -
-
-
-
-
- -
Total members
-
- {project.total_members} + ) : project.icon_prop ? ( +
+ + {project.icon_prop.name} + +
+ ) : ( + + {project?.name.charAt(0)} + + )} +
+

{truncateText(project.name, 20)}

+ + ({project.identifier}) + +
-
-
- -
Total cycles
+
+
+
+ +
Total members
+
+ {project.total_members}
- {project.total_cycles} -
-
-
- -
Total modules
+
+
+ +
Total cycles
+
+ {project.total_cycles} +
+
+
+ +
Total modules
+
+ {project.total_modules}
- {project.total_modules}
-
- ); + ); })}
diff --git a/apps/app/components/automation/auto-archive-automation.tsx b/apps/app/components/automation/auto-archive-automation.tsx new file mode 100644 index 000000000..8d1ba4ebe --- /dev/null +++ b/apps/app/components/automation/auto-archive-automation.tsx @@ -0,0 +1,95 @@ +import React, { useState } from "react"; + +// component +import { CustomSelect, ToggleSwitch } from "components/ui"; +import { SelectMonthModal } from "components/automation"; +// icons +import { ChevronDownIcon } from "@heroicons/react/24/outline"; +// constants +import { PROJECT_AUTOMATION_MONTHS } from "constants/project"; +// types +import { IProject } from "types"; + +type Props = { + projectDetails: IProject | undefined; + handleChange: (formData: Partial) => Promise; +}; + +export const AutoArchiveAutomation: React.FC = ({ projectDetails, handleChange }) => { + const [monthModal, setmonthModal] = useState(false); + + const initialValues: Partial = { archive_in: 1 }; + return ( + <> + setmonthModal(false)} + handleChange={handleChange} + /> +
+
+
+

Auto-archive closed issues

+

+ Plane will automatically archive issues that have been completed or cancelled for the + configured time period. +

+
+ + projectDetails?.archive_in === 0 + ? handleChange({ archive_in: 1 }) + : handleChange({ archive_in: 0 }) + } + size="sm" + /> +
+ {projectDetails?.archive_in !== 0 && ( +
+
+ Auto-archive issues that are closed for +
+
+ + {`${projectDetails?.archive_in} ${ + projectDetails?.archive_in === 1 ? "Month" : "Months" + }`} + +
+
+ )} +
+ + ); +}; diff --git a/apps/app/components/automation/auto-close-automation.tsx b/apps/app/components/automation/auto-close-automation.tsx new file mode 100644 index 000000000..4d46069e9 --- /dev/null +++ b/apps/app/components/automation/auto-close-automation.tsx @@ -0,0 +1,190 @@ +import React, { useState } from "react"; + +import useSWR from "swr"; + +import { useRouter } from "next/router"; + +// component +import { CustomSearchSelect, CustomSelect, ToggleSwitch } from "components/ui"; +import { SelectMonthModal } from "components/automation"; +// icons +import { ChevronDownIcon, Squares2X2Icon } from "@heroicons/react/24/outline"; +import { getStateGroupIcon } from "components/icons"; +// services +import stateService from "services/state.service"; +// constants +import { PROJECT_AUTOMATION_MONTHS } from "constants/project"; +import { STATES_LIST } from "constants/fetch-keys"; +// types +import { IProject } from "types"; +// helper +import { getStatesList } from "helpers/state.helper"; + +type Props = { + projectDetails: IProject | undefined; + handleChange: (formData: Partial) => Promise; +}; + +export const AutoCloseAutomation: React.FC = ({ projectDetails, handleChange }) => { + const [monthModal, setmonthModal] = useState(false); + + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const { data: stateGroups } = useSWR( + workspaceSlug && projectId ? STATES_LIST(projectId as string) : null, + workspaceSlug && projectId + ? () => stateService.getStates(workspaceSlug as string, projectId as string) + : null + ); + + const states = getStatesList(stateGroups ?? {}); + + const options = states + ?.filter((state) => state.group === "cancelled") + .map((state) => ({ + value: state.id, + query: state.name, + content: ( +
+ {getStateGroupIcon(state.group, "16", "16", state.color)} + {state.name} +
+ ), + })); + + const multipleOptions = options.length > 1; + + const defaultState = stateGroups && stateGroups.cancelled ? stateGroups.cancelled[0].id : null; + + const selectedOption = states?.find( + (s) => s.id === projectDetails?.default_state ?? defaultState + ); + const currentDefaultState = states.find((s) => s.id === defaultState); + + const initialValues: Partial = { + close_in: 1, + default_state: defaultState, + }; + + return ( + <> + setmonthModal(false)} + handleChange={handleChange} + /> + +
+
+
+

Auto-close inactive issues

+

+ Plane will automatically close the issues that have not been updated for the + configured time period. +

+
+ + projectDetails?.close_in === 0 + ? handleChange({ close_in: 1, default_state: defaultState }) + : handleChange({ close_in: 0, default_state: null }) + } + size="sm" + /> +
+ {projectDetails?.close_in !== 0 && ( +
+
+
+ Auto-close issues that are inactive for +
+
+ + {`${projectDetails?.close_in} ${ + projectDetails?.close_in === 1 ? "Month" : "Months" + }`} + +
+
+
+
Auto-close Status
+
+ +
+ {selectedOption ? ( + getStateGroupIcon(selectedOption.group, "16", "16", selectedOption.color) + ) : currentDefaultState ? ( + getStateGroupIcon( + currentDefaultState.group, + "16", + "16", + currentDefaultState.color + ) + ) : ( + + )} + {selectedOption?.name + ? selectedOption.name + : currentDefaultState?.name ?? ( + State + )} +
+ {multipleOptions && ( +
+
+
+ )} +
+ + ); +}; diff --git a/apps/app/components/automation/index.ts b/apps/app/components/automation/index.ts new file mode 100644 index 000000000..73decae11 --- /dev/null +++ b/apps/app/components/automation/index.ts @@ -0,0 +1,3 @@ +export * from "./auto-close-automation"; +export * from "./auto-archive-automation"; +export * from "./select-month-modal"; diff --git a/apps/app/components/automation/select-month-modal.tsx b/apps/app/components/automation/select-month-modal.tsx new file mode 100644 index 000000000..b91c03391 --- /dev/null +++ b/apps/app/components/automation/select-month-modal.tsx @@ -0,0 +1,147 @@ +import React from "react"; + +import { useRouter } from "next/router"; + +// react-hook-form +import { useForm } from "react-hook-form"; +// headless ui +import { Dialog, Transition } from "@headlessui/react"; +// ui +import { Input, PrimaryButton, SecondaryButton } from "components/ui"; +// types +import type { IProject } from "types"; + +// types +type Props = { + isOpen: boolean; + type: "auto-close" | "auto-archive"; + initialValues: Partial; + handleClose: () => void; + handleChange: (formData: Partial) => Promise; +}; + +export const SelectMonthModal: React.FC = ({ + type, + initialValues, + isOpen, + handleClose, + handleChange, +}) => { + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const { + register, + formState: { errors, isSubmitting }, + handleSubmit, + reset, + } = useForm({ + defaultValues: initialValues, + }); + + const onClose = () => { + handleClose(); + reset(initialValues); + }; + + const onSubmit = (formData: Partial) => { + if (!workspaceSlug && !projectId) return; + handleChange(formData); + onClose(); + }; + + const inputSection = (name: string) => ( +
+ + Months +
+ ); + + return ( + + + +
+ + +
+
+ + +
+
+ + Customize Time Range + +
+
+ {type === "auto-close" ? ( + <> + {inputSection("close_in")} + {errors.close_in && ( + + Select a month between 1 and 12. + + )} + + ) : ( + <> + {inputSection("archive_in")} + {errors.archive_in && ( + + Select a month between 1 and 12. + + )} + + )} +
+
+
+
+ Cancel + + {isSubmitting ? "Submitting..." : "Submit"} + +
+
+
+
+
+
+
+
+ ); +}; diff --git a/apps/app/components/core/board-view/single-issue.tsx b/apps/app/components/core/board-view/single-issue.tsx index 27a95d29e..e019d3a9e 100644 --- a/apps/app/components/core/board-view/single-issue.tsx +++ b/apps/app/components/core/board-view/single-issue.tsx @@ -334,9 +334,7 @@ export const SingleBoardIssue: React.FC = ({ {issue.project_detail.identifier}-{issue.sequence_id}
)} -
- {issue.name} -
+
{issue.name}
diff --git a/apps/app/components/core/feeds.tsx b/apps/app/components/core/feeds.tsx index bc915d294..27be9ba31 100644 --- a/apps/app/components/core/feeds.tsx +++ b/apps/app/components/core/feeds.tsx @@ -23,6 +23,7 @@ import { renderShortDateWithYearFormat, timeAgo } from "helpers/date-time.helper import { addSpaceIfCamelCase } from "helpers/string.helper"; // types import RemirrorRichTextEditor from "components/rich-text-editor"; +import { Icon } from "components/ui"; const activityDetails: { [key: string]: { @@ -105,6 +106,10 @@ const activityDetails: { message: "updated the attachment", icon: