From ad1a074292a15d5995d070d118d40b38b2192d2f Mon Sep 17 00:00:00 2001 From: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com> Date: Tue, 11 Jul 2023 13:30:55 +0530 Subject: [PATCH 01/24] chore: environment variables for worker and api (#1492) --- docker-compose-hub.yml | 86 +++++++++++++++++------------------------- 1 file changed, 34 insertions(+), 52 deletions(-) diff --git a/docker-compose-hub.yml b/docker-compose-hub.yml index 63f196300..cc4fe690b 100644 --- a/docker-compose-hub.yml +++ b/docker-compose-hub.yml @@ -1,5 +1,37 @@ version: "3.8" +x-api-and-worker-env: &api-and-worker-env + DEBUG: ${DEBUG} + SENTRY_DSN: ${SENTRY_DSN} + DJANGO_SETTINGS_MODULE: plane.settings.production + DATABASE_URL: postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:5432/${PGDATABASE} + REDIS_URL: redis://plane-redis:6379/ + EMAIL_HOST: ${EMAIL_HOST} + EMAIL_HOST_USER: ${EMAIL_HOST_USER} + EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD} + EMAIL_PORT: ${EMAIL_PORT} + EMAIL_FROM: ${EMAIL_FROM} + EMAIL_USE_TLS: ${EMAIL_USE_TLS} + EMAIL_USE_SSL: ${EMAIL_USE_SSL} + AWS_REGION: ${AWS_REGION} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} + AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME} + AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL} + FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT} + WEB_URL: ${WEB_URL} + GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET} + DISABLE_COLLECTSTATIC: 1 + DOCKERIZED: 1 + OPENAI_API_KEY: ${OPENAI_API_KEY} + GPT_ENGINE: ${GPT_ENGINE} + SECRET_KEY: ${SECRET_KEY} + DEFAULT_EMAIL: ${DEFAULT_EMAIL} + DEFAULT_PASSWORD: ${DEFAULT_PASSWORD} + USE_MINIO: ${USE_MINIO} + ENABLE_SIGNUP: ${ENABLE_SIGNUP} + + services: plane-web: container_name: planefrontend @@ -28,32 +60,7 @@ services: env_file: - .env environment: - DEBUG: ${DEBUG} - SENTRY_DSN: ${SENTRY_DSN} - DJANGO_SETTINGS_MODULE: plane.settings.production - DATABASE_URL: postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:5432/${PGDATABASE} - REDIS_URL: redis://plane-redis:6379/ - EMAIL_HOST: ${EMAIL_HOST} - EMAIL_HOST_USER: ${EMAIL_HOST_USER} - EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD} - EMAIL_PORT: ${EMAIL_PORT} - EMAIL_FROM: ${EMAIL_FROM} - EMAIL_USE_TLS: ${EMAIL_USE_TLS} - AWS_REGION: ${AWS_REGION} - AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} - AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} - AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME} - FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT} - WEB_URL: ${WEB_URL} - GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET} - DISABLE_COLLECTSTATIC: 1 - DOCKERIZED: 1 - OPENAI_API_KEY: ${OPENAI_API_KEY} - GPT_ENGINE: ${GPT_ENGINE} - SECRET_KEY: ${SECRET_KEY} - DEFAULT_EMAIL: ${DEFAULT_EMAIL} - DEFAULT_PASSWORD: ${DEFAULT_PASSWORD} - USE_MINIO: ${USE_MINIO} + <<: *api-and-worker-env depends_on: - plane-db - plane-redis @@ -66,32 +73,7 @@ services: env_file: - .env environment: - DEBUG: ${DEBUG} - SENTRY_DSN: ${SENTRY_DSN} - DJANGO_SETTINGS_MODULE: plane.settings.production - DATABASE_URL: postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:5432/${PGDATABASE} - REDIS_URL: redis://plane-redis:6379/ - EMAIL_HOST: ${EMAIL_HOST} - EMAIL_HOST_USER: ${EMAIL_HOST_USER} - EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD} - EMAIL_PORT: ${EMAIL_PORT} - EMAIL_FROM: ${EMAIL_FROM} - EMAIL_USE_TLS: ${EMAIL_USE_TLS} - AWS_REGION: ${AWS_REGION} - AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} - AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} - AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME} - FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT} - WEB_URL: ${WEB_URL} - GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET} - DISABLE_COLLECTSTATIC: 1 - DOCKERIZED: 1 - OPENAI_API_KEY: ${OPENAI_API_KEY} - GPT_ENGINE: ${GPT_ENGINE} - SECRET_KEY: ${SECRET_KEY} - DEFAULT_EMAIL: ${DEFAULT_EMAIL} - DEFAULT_PASSWORD: ${DEFAULT_PASSWORD} - USE_MINIO: ${USE_MINIO} + <<: *api-and-worker-env depends_on: - plane-api - plane-db From abdb4a4778e16e0d3443202008c715bfac777515 Mon Sep 17 00:00:00 2001 From: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com> Date: Tue, 11 Jul 2023 13:36:31 +0530 Subject: [PATCH 02/24] feat: notifications (#1363) * feat: added new issue subscriber table * dev: notification model * feat: added CRUD operation for issue subscriber * Revert "feat: added CRUD operation for issue subscriber" This reverts commit b22e0625768f0b096b5898936ace76d6882b0736. * feat: added CRUD operation for issue subscriber * dev: notification models and operations * dev: remove delete endpoint response data * dev: notification endpoints and fix bg worker for saving notifications * feat: added list and unsubscribe function in issue subscriber * dev: filter by snoozed and response update for list and permissions * dev: update issue notifications * dev: notification segregation * dev: update notifications * dev: notification filtering * dev: add issue name in notifications * dev: notification new endpoints --------- Co-authored-by: NarayanBavisetti --- apiserver/plane/api/serializers/__init__.py | 5 + apiserver/plane/api/serializers/issue.py | 12 + .../plane/api/serializers/notification.py | 10 + apiserver/plane/api/serializers/project.py | 17 ++ apiserver/plane/api/urls.py | 74 +++++++ apiserver/plane/api/views/__init__.py | 4 + apiserver/plane/api/views/issue.py | 160 ++++++++++++++ apiserver/plane/api/views/notification.py | 205 ++++++++++++++++++ .../plane/bgtasks/issue_activites_task.py | 85 +++++++- apiserver/plane/db/models/__init__.py | 4 + apiserver/plane/db/models/issue.py | 21 ++ apiserver/plane/db/models/notification.py | 37 ++++ 12 files changed, 623 insertions(+), 11 deletions(-) create mode 100644 apiserver/plane/api/serializers/notification.py create mode 100644 apiserver/plane/api/views/notification.py create mode 100644 apiserver/plane/db/models/notification.py 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..34e711be6 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -76,6 +76,7 @@ from plane.api.views import ( IssueLinkViewSet, BulkCreateIssueLabelsEndpoint, IssueAttachmentEndpoint, + IssueSubscriberViewSet, ## End Issues # States StateViewSet, @@ -148,6 +149,9 @@ from plane.api.views import ( ExportAnalyticsEndpoint, DefaultAnalyticsEndpoint, ## End Analytics + # Notification + NotificationViewSet, + ## End Notification ) @@ -797,6 +801,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/", @@ -1273,4 +1305,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..327dd6037 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -65,6 +65,7 @@ from .issue import ( IssueLinkViewSet, BulkCreateIssueLabelsEndpoint, IssueAttachmentEndpoint, + IssueSubscriberViewSet, ) from .auth_extended import ( @@ -133,6 +134,7 @@ from .estimate import ( from .release import ReleaseNotesEndpoint from .inbox import InboxViewSet, InboxIssueViewSet + from .analytic import ( AnalyticsEndpoint, AnalyticViewViewset, @@ -140,3 +142,5 @@ from .analytic import ( ExportAnalyticsEndpoint, DefaultAnalyticsEndpoint, ) + +from .notification import NotificationViewSet \ No newline at end of file diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index bfefc91ba..d96441c75 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,156 @@ class IssueAttachmentEndpoint(BaseAPIView): {"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..fa1e280d6 --- /dev/null +++ b/apiserver/plane/api/views/notification.py @@ -0,0 +1,205 @@ +# 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") + + # 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( + snoozed_till__lt=timezone.now(), + ) + + # Filter for archived or unarchive + if archived == "true": + notifications = notifications.filter(archived_at__isnull=True) + + if archived == "false": + notifications = notifications.filter(archived_at__isnull=False) + + # Subscribed issues + if type == "watching": + issue_ids = IssueSubscriber.objects.filter( + workspace__slug=slug, subsriber_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/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 5865a5982..7bb6010dd 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -20,6 +20,9 @@ from plane.db.models import ( State, Cycle, Module, + IssueSubscriber, + Notification, + IssueAssignee, ) from plane.api.serializers import IssueActivitySerializer @@ -958,6 +961,12 @@ def issue_activity( actor = User.objects.get(pk=actor_id) project = Project.objects.get(pk=project_id) + # add the user to issue subscriber + try: + _ = IssueSubscriber.objects.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 +1001,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/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..4b765a516 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -401,6 +401,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}>" From 7087b1b5f2f67392704aaca488f85a4cb3760265 Mon Sep 17 00:00:00 2001 From: Quadrubo <71718414+Quadrubo@users.noreply.github.com> Date: Tue, 11 Jul 2023 10:40:57 +0200 Subject: [PATCH 03/24] fix: docker inconsistencies (#1493) Co-authored-by: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com> --- docker-compose-hub.yml | 39 +++++++++++++++++++-------------------- docker-compose.yml | 4 ++-- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/docker-compose-hub.yml b/docker-compose-hub.yml index cc4fe690b..16f2bbd86 100644 --- a/docker-compose-hub.yml +++ b/docker-compose-hub.yml @@ -31,7 +31,6 @@ x-api-and-worker-env: &api-and-worker-env USE_MINIO: ${USE_MINIO} ENABLE_SIGNUP: ${ENABLE_SIGNUP} - services: plane-web: container_name: planefrontend @@ -42,12 +41,14 @@ services: - .env environment: NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL} - NEXT_PUBLIC_GOOGLE_CLIENTID: 0 - NEXT_PUBLIC_GITHUB_APP_NAME: 0 - NEXT_PUBLIC_GITHUB_ID: 0 - NEXT_PUBLIC_SENTRY_DSN: 0 - NEXT_PUBLIC_ENABLE_OAUTH: 0 - NEXT_PUBLIC_ENABLE_SENTRY: 0 + NEXT_PUBLIC_GOOGLE_CLIENTID: "0" + NEXT_PUBLIC_GITHUB_APP_NAME: "0" + NEXT_PUBLIC_GITHUB_ID: "0" + NEXT_PUBLIC_SENTRY_DSN: "0" + NEXT_PUBLIC_ENABLE_OAUTH: "0" + NEXT_PUBLIC_ENABLE_SENTRY: "0" + NEXT_PUBLIC_ENABLE_SESSION_RECORDER: "0" + NEXT_PUBLIC_TRACK_EVENTS: "0" depends_on: - plane-api - plane-worker @@ -66,7 +67,7 @@ services: - plane-redis plane-worker: - container_name: planerqworker + container_name: planebgworker image: makeplane/plane-worker:latest restart: always command: ./bin/worker @@ -84,14 +85,15 @@ services: image: postgres:15.2-alpine restart: always command: postgres -c 'max_connections=1000' + volumes: + - pgdata:/var/lib/postgresql/data env_file: - .env environment: POSTGRES_USER: ${PGUSER} POSTGRES_DB: ${PGDATABASE} POSTGRES_PASSWORD: ${PGPASSWORD} - volumes: - - pgdata:/var/lib/postgresql/data + PGDATA: /var/lib/postgresql/data plane-redis: container_name: plane-redis @@ -103,9 +105,10 @@ services: plane-minio: container_name: plane-minio image: minio/minio + restart: always + command: server /export --console-address ":9090" volumes: - uploads:/export - command: server /export --console-address ":9090" env_file: - .env environment: @@ -115,23 +118,20 @@ services: createbuckets: image: minio/mc entrypoint: > - /bin/sh -c " - /usr/bin/mc config host add plane-minio http://plane-minio:9000 \$AWS_ACCESS_KEY_ID \$AWS_SECRET_ACCESS_KEY; - /usr/bin/mc mb plane-minio/\$AWS_S3_BUCKET_NAME; - /usr/bin/mc anonymous set download plane-minio/\$AWS_S3_BUCKET_NAME; - exit 0; - " + /bin/sh -c " /usr/bin/mc config host add plane-minio http://plane-minio:9000 \$AWS_ACCESS_KEY_ID \$AWS_SECRET_ACCESS_KEY; + /usr/bin/mc mb plane-minio/\$AWS_S3_BUCKET_NAME; + /usr/bin/mc anonymous set download plane-minio/\$AWS_S3_BUCKET_NAME; exit 0; " env_file: - .env depends_on: - plane-minio -# Comment this if you already have a reverse proxy running + # Comment this if you already have a reverse proxy running plane-proxy: container_name: planeproxy image: makeplane/plane-proxy:latest ports: - - ${NGINX_PORT}:80 + - ${NGINX_PORT}:80 env_file: - .env environment: @@ -141,7 +141,6 @@ services: - plane-web - plane-api - volumes: pgdata: redisdata: diff --git a/docker-compose.yml b/docker-compose.yml index 496ee434d..a8cf39113 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,7 +40,7 @@ services: args: NEXT_PUBLIC_API_BASE_URL: http://localhost:8000 restart: always - command: [ "/usr/local/bin/start.sh" ] + command: /usr/local/bin/start.sh env_file: - .env environment: @@ -57,7 +57,6 @@ services: - plane-api - plane-worker - plane-api: container_name: planebackend build: @@ -135,6 +134,7 @@ services: depends_on: - plane-minio + # Comment this if you already have a reverse proxy running plane-proxy: container_name: planeproxy build: From 7554988164e528a4c88a11df3018028b1cc933e9 Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Tue, 11 Jul 2023 14:35:20 +0530 Subject: [PATCH 04/24] feat: issue archival and close (#1474) * chore: added issue archive using celery beat * chore: changed the file name * fix: created API and updated logic for achived-issues * chore: added issue activity message * chore: added the beat scheduler command * feat: added unarchive issue functionality * feat: auto issue close * dev: refactor endpoints and issue archive activity * dev: update manager for global filtering * fix: added id in issue unarchive url * dev: update auto close to include default close state * fix: updated the list and retrive function * fix: added the prefetch fields * dev: update unarchive --------- Co-authored-by: pablohashescobar --- apiserver/Procfile | 3 +- apiserver/plane/api/urls.py | 31 +++ apiserver/plane/api/views/__init__.py | 1 + apiserver/plane/api/views/issue.py | 191 ++++++++++++++++++ .../plane/bgtasks/issue_activites_task.py | 24 +++ .../plane/bgtasks/issue_automation_task.py | 146 +++++++++++++ apiserver/plane/celery.py | 11 + apiserver/plane/db/models/issue.py | 3 + apiserver/plane/db/models/project.py | 10 + apiserver/plane/settings/common.py | 2 + apiserver/requirements/base.txt | 3 +- 11 files changed, 423 insertions(+), 2 deletions(-) create mode 100644 apiserver/plane/bgtasks/issue_automation_task.py 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/urls.py b/apiserver/plane/api/urls.py index 34e711be6..1958f5c18 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -76,6 +76,7 @@ from plane.api.views import ( IssueLinkViewSet, BulkCreateIssueLabelsEndpoint, IssueAttachmentEndpoint, + IssueArchiveViewSet, IssueSubscriberViewSet, ## End Issues # States @@ -853,6 +854,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/", diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 327dd6037..9eba0868a 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -65,6 +65,7 @@ from .issue import ( IssueLinkViewSet, BulkCreateIssueLabelsEndpoint, IssueAttachmentEndpoint, + IssueArchiveViewSet, IssueSubscriberViewSet, ) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index d96441c75..415e7e2fa 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -914,6 +914,197 @@ class IssueAttachmentEndpoint(BaseAPIView): ) +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_in": 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 diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 7bb6010dd..26f617033 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 @@ -557,6 +558,22 @@ def track_estimate_points( ) +def track_archive_in( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + 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="archvied_at", + ) + ) + + def update_issue_activity( requested_data, current_instance, issue_id, project, actor, issue_activities ): @@ -573,6 +590,7 @@ def update_issue_activity( "blocks_list": track_blocks, "blockers_list": track_blockings, "estimate_point": track_estimate_points, + "archived_in": track_archive_in, } requested_data = json.loads(requested_data) if requested_data is not None else None @@ -950,6 +968,7 @@ def delete_attachment_activity( ) + # Receive message from room group @shared_task def issue_activity( @@ -961,6 +980,11 @@ 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() + # add the user to issue subscriber try: _ = IssueSubscriber.objects.create(issue_id=issue_id, subscriber=actor) diff --git a/apiserver/plane/bgtasks/issue_automation_task.py b/apiserver/plane/bgtasks/issue_automation_task.py new file mode 100644 index 000000000..c52994a43 --- /dev/null +++ b/apiserver/plane/bgtasks/issue_automation_task.py @@ -0,0 +1,146 @@ +# Python improts +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, IssueActivity, State + + +@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) + IssueActivity.objects.bulk_create( + [ + IssueActivity( + issue_id=issue.id, + actor=project.created_by, + verb="updated", + field="archived_at", + project=project, + workspace=project.workspace, + comment="Plane archived the issue", + ) + for issue in issues_to_update + ], + batch_size=100, + ) + 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 = project.default_state + else: + close_state = State.objects.filter(group="cancelled").first() + + + 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) + IssueActivity.objects.bulk_create( + [ + IssueActivity( + issue_id=issue.id, + actor=project.created_by, + verb="updated", + field="state", + project=project, + workspace=project.workspace, + comment="Plane cancelled the issue", + ) + for issue in issues_to_update + ], + batch_size=100, + ) + return + except Exception as e: + if settings.DEBUG: + print(e) + capture_exception(e) + return \ No newline at end of file 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/issue.py b/apiserver/plane/db/models/issue.py index 4b765a516..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() 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/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/requirements/base.txt b/apiserver/requirements/base.txt index 537564828..c4fa8ef2c 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -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 From 253edebb9326ee3fcd2ee69fc184125f2d95d0b3 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Tue, 11 Jul 2023 15:18:47 +0530 Subject: [PATCH 05/24] fix: updated text and background colors (#1496) * fix: custom colors opacity * chore: update text colors for dark mode * fix: dropdown text colors, datepicker bg color * chore: update text colors * chore: updated primary bg color --- .../analytics/custom-analytics/sidebar.tsx | 4 +- .../core/board-view/single-issue.tsx | 4 +- .../core/filters/issues-view-filter.tsx | 88 ++++++++----------- .../components/cycles/single-cycle-card.tsx | 6 +- .../components/cycles/single-cycle-list.tsx | 23 +++-- .../components/estimates/single-estimate.tsx | 7 +- .../issues/view-select/due-date.tsx | 7 +- .../components/issues/view-select/label.tsx | 41 +++++---- .../components/issues/view-select/state.tsx | 3 +- .../components/modules/single-module-card.tsx | 4 +- .../project/create-project-modal.tsx | 2 +- .../project/single-sidebar-project.tsx | 4 +- apps/app/components/ui/custom-menu.tsx | 2 +- .../components/ui/custom-search-select.tsx | 6 +- apps/app/components/ui/custom-select.tsx | 4 +- apps/app/components/ui/datepicker.tsx | 4 +- apps/app/components/ui/empty-space.tsx | 8 +- .../components/ui/multi-level-dropdown.tsx | 2 +- .../components/workspace/sidebar-dropdown.tsx | 12 ++- apps/app/constants/issue.ts | 2 + .../projects/[projectId]/cycles/[cycleId].tsx | 2 +- .../projects/[projectId]/issues/index.tsx | 4 +- .../[projectId]/modules/[moduleId].tsx | 2 +- .../[projectId]/settings/estimates.tsx | 12 +-- .../[projectId]/settings/features.tsx | 18 +++- .../[projectId]/settings/integrations.tsx | 4 +- .../projects/[projectId]/settings/states.tsx | 4 +- apps/app/styles/globals.css | 16 ++-- apps/app/tailwind.config.js | 2 +- 29 files changed, 158 insertions(+), 139 deletions(-) diff --git a/apps/app/components/analytics/custom-analytics/sidebar.tsx b/apps/app/components/analytics/custom-analytics/sidebar.tsx index 54428486b..64428a468 100644 --- a/apps/app/components/analytics/custom-analytics/sidebar.tsx +++ b/apps/app/components/analytics/custom-analytics/sidebar.tsx @@ -238,8 +238,8 @@ export const AnalyticsSidebar: React.FC = ({ {project?.name.charAt(0)} )} -
- {project.name} +
+

{project.name}

({project.identifier}) 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/filters/issues-view-filter.tsx b/apps/app/components/core/filters/issues-view-filter.tsx index 2958b966d..8b625fbcf 100644 --- a/apps/app/components/core/filters/issues-view-filter.tsx +++ b/apps/app/components/core/filters/issues-view-filter.tsx @@ -23,10 +23,33 @@ import { import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; import { checkIfArraysHaveSameElements } from "helpers/array.helper"; // types -import { Properties } from "types"; +import { Properties, TIssueViewOptions } from "types"; // constants import { GROUP_BY_OPTIONS, ORDER_BY_OPTIONS, FILTER_ISSUE_OPTIONS } from "constants/issue"; +const issueViewOptions: { type: TIssueViewOptions; icon: any }[] = [ + { + type: "list", + icon: , + }, + { + type: "kanban", + icon: , + }, + { + type: "calendar", + icon: , + }, + { + type: "spreadsheet", + icon: , + }, + { + type: "gantt_chart", + icon: , + }, +]; + export const IssuesFilterView: React.FC = () => { const router = useRouter(); const { workspaceSlug, projectId, viewId } = router.query; @@ -56,53 +79,20 @@ export const IssuesFilterView: React.FC = () => { return (
- - - - - + {issueViewOptions.map((option) => ( + + ))}
{ {({ open }) => ( <> = ({ e.preventDefault(); handleEditCycle(); }} - className="flex cursor-pointer items-center rounded p-1 text-custom-text-200 duration-300 hover:bg-custom-background-90" + className="cursor-pointer rounded p-1 text-custom-text-200 duration-300 hover:bg-custom-background-80" > - - - + )} diff --git a/apps/app/components/cycles/single-cycle-list.tsx b/apps/app/components/cycles/single-cycle-list.tsx index 33cc5e3b6..32bd18539 100644 --- a/apps/app/components/cycles/single-cycle-list.tsx +++ b/apps/app/components/cycles/single-cycle-list.tsx @@ -1,7 +1,6 @@ import React, { useEffect, useState } from "react"; import Link from "next/link"; -import Image from "next/image"; import { useRouter } from "next/router"; // hooks @@ -157,7 +156,7 @@ export const SingleCycleList: React.FC = ({
- +
= ({ position="top-left" >

- {truncateText(cycle.name, 70)} + {truncateText(cycle.name, 60)}

{cycle.description}

-
- +
+
= ({ }`} > {cycleStatus === "current" ? ( - + - {findHowManyDaysLeft(cycle.end_date ?? new Date())} Days Left + {findHowManyDaysLeft(cycle.end_date ?? new Date())} days left ) : cycleStatus === "upcoming" ? ( - {findHowManyDaysLeft(cycle.start_date ?? new Date())} Days Left + {findHowManyDaysLeft(cycle.start_date ?? new Date())} days left ) : cycleStatus === "completed" ? ( @@ -236,12 +235,12 @@ export const SingleCycleList: React.FC = ({ {cycleStatus !== "draft" && (
-
+
{renderShortDateWithYearFormat(startDate)}
-
+
{renderShortDateWithYearFormat(endDate)}
@@ -287,7 +286,7 @@ export const SingleCycleList: React.FC = ({ }`} > {cycleStatus === "current" ? ( - + {cycle.total_issues > 0 ? ( <> = ({
- +
diff --git a/apps/app/components/estimates/single-estimate.tsx b/apps/app/components/estimates/single-estimate.tsx index 21ffe4aff..17c111559 100644 --- a/apps/app/components/estimates/single-estimate.tsx +++ b/apps/app/components/estimates/single-estimate.tsx @@ -72,7 +72,7 @@ export const SingleEstimate: React.FC = ({
{estimate.name} {projectDetails?.estimate && projectDetails?.estimate === estimate.id && ( - + In use )} @@ -83,7 +83,10 @@ export const SingleEstimate: React.FC = ({
{projectDetails?.estimate !== estimate.id && estimate.points.length > 0 && ( - + Use )} diff --git a/apps/app/components/issues/view-select/due-date.tsx b/apps/app/components/issues/view-select/due-date.tsx index efd568c30..f45440cfc 100644 --- a/apps/app/components/issues/view-select/due-date.tsx +++ b/apps/app/components/issues/view-select/due-date.tsx @@ -8,6 +8,7 @@ import { findHowManyDaysLeft, renderShortDateWithYearFormat } from "helpers/date import trackEventServices from "services/track-event.service"; // types import { ICurrentUserResponse, IIssue } from "types"; +import useIssuesView from "hooks/use-issues-view"; type Props = { issue: IIssue; @@ -29,6 +30,8 @@ export const ViewDueDateSelect: React.FC = ({ const router = useRouter(); const { workspaceSlug } = router.query; + const { issueView } = useIssuesView(); + return ( = ({ user ); }} - className={issue?.target_date ? "w-[6.5rem]" : "w-[5rem] text-center"} + className={`${issue?.target_date ? "w-[6.5rem]" : "w-[5rem] text-center"} ${ + issueView === "kanban" ? "bg-custom-background-90" : "bg-custom-background-100" + }`} noBorder={noBorder} disabled={isNotAllowed} /> diff --git a/apps/app/components/issues/view-select/label.tsx b/apps/app/components/issues/view-select/label.tsx index 826ba6560..098576dd7 100644 --- a/apps/app/components/issues/view-select/label.tsx +++ b/apps/app/components/issues/view-select/label.tsx @@ -71,9 +71,15 @@ export const ViewLabelSelect: React.FC = ({ position={tooltipPosition} tooltipHeading="Labels" tooltipContent={ - issue.label_details.length > 0 - ? issue.label_details.map((label) => label.name ?? "").join(", ") - : "No Label" + issue.labels.length > 0 + ? issue.labels + .map((labelId) => { + const label = issueLabels?.find((l) => l.id === labelId); + + return label?.name ?? ""; + }) + .join(", ") + : "No label" } >
= ({ isNotAllowed ? "cursor-not-allowed" : "cursor-pointer" } items-center gap-2 text-custom-text-200`} > - {issue.label_details.length > 0 ? ( + {issue.labels.length > 0 ? ( <> - {issue.label_details.slice(0, 4).map((label, index) => ( -
- -
- ))} - {issue.label_details.length > 4 ? +{issue.label_details.length - 4} : null} + {issue.labels.slice(0, 4).map((labelId, index) => { + const label = issueLabels?.find((l) => l.id === labelId); + + return ( +
+ +
+ ); + })} + {issue.labels.length > 4 ? +{issue.labels.length - 4} : null} ) : ( <> diff --git a/apps/app/components/issues/view-select/state.tsx b/apps/app/components/issues/view-select/state.tsx index 75d158faf..6f679fe7c 100644 --- a/apps/app/components/issues/view-select/state.tsx +++ b/apps/app/components/issues/view-select/state.tsx @@ -10,7 +10,6 @@ import { CustomSearchSelect, Tooltip } from "components/ui"; // icons import { getStateGroupIcon } from "components/icons"; // helpers -import { addSpaceIfCamelCase } from "helpers/string.helper"; import { getStatesList } from "helpers/state.helper"; // types import { ICurrentUserResponse, IIssue } from "types"; @@ -67,7 +66,7 @@ export const ViewStateSelect: React.FC = ({ const stateLabel = (
diff --git a/apps/app/components/modules/single-module-card.tsx b/apps/app/components/modules/single-module-card.tsx index a91c29763..16535e77f 100644 --- a/apps/app/components/modules/single-module-card.tsx +++ b/apps/app/components/modules/single-module-card.tsx @@ -185,12 +185,12 @@ export const SingleModuleCard: React.FC = ({ module, handleEditModule, us
Start: - {renderShortDateWithYearFormat(startDate)} + {renderShortDateWithYearFormat(startDate, "Not set")}
End: - {renderShortDateWithYearFormat(endDate)} + {renderShortDateWithYearFormat(endDate, "Not set")}
diff --git a/apps/app/components/project/create-project-modal.tsx b/apps/app/components/project/create-project-modal.tsx index 06a3ff29b..5f289d7c5 100644 --- a/apps/app/components/project/create-project-modal.tsx +++ b/apps/app/components/project/create-project-modal.tsx @@ -184,7 +184,7 @@ export const CreateProjectModal: React.FC = (props) => { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - +
{watch("cover_image") !== null && ( = ({ )} {!sidebarCollapse && ( -

+

{truncateText(project?.name, 20)} -

+
)}
{!sidebarCollapse && ( diff --git a/apps/app/components/ui/custom-menu.tsx b/apps/app/components/ui/custom-menu.tsx index a7014b633..e42e49d21 100644 --- a/apps/app/components/ui/custom-menu.tsx +++ b/apps/app/components/ui/custom-menu.tsx @@ -59,7 +59,7 @@ const CustomMenu = ({ {ellipsis || verticalEllipsis ? ( `${active || selected ? "bg-custom-background-80" : ""} ${ - selected ? "font-medium" : "" - } flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 text-custom-text-200` + selected ? "text-custom-text-100" : "text-custom-text-200" + } flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5` } > {({ active, selected }) => ( @@ -157,7 +157,7 @@ export const CustomSearchSelect = ({ {option.content} {multiple ? (
diff --git a/apps/app/components/ui/custom-select.tsx b/apps/app/components/ui/custom-select.tsx index 1b5513666..be26ec5be 100644 --- a/apps/app/components/ui/custom-select.tsx +++ b/apps/app/components/ui/custom-select.tsx @@ -118,8 +118,8 @@ const Option: React.FC = ({ children, value, className }) => ( value={value} className={({ active, selected }) => `${className} ${active || selected ? "bg-custom-background-80" : ""} ${ - selected ? "font-medium" : "" - } cursor-pointer select-none truncate rounded px-1 py-1.5 text-custom-text-200` + selected ? "text-custom-text-100" : "text-custom-text-200" + } cursor-pointer select-none truncate rounded px-1 py-1.5` } > {({ selected }) => ( diff --git a/apps/app/components/ui/datepicker.tsx b/apps/app/components/ui/datepicker.tsx index 106fe9fad..a13220ee6 100644 --- a/apps/app/components/ui/datepicker.tsx +++ b/apps/app/components/ui/datepicker.tsx @@ -42,13 +42,13 @@ export const CustomDatePicker: React.FC = ({ : renderAs === "button" ? `px-2 py-1 text-xs shadow-sm ${ disabled ? "" : "hover:bg-custom-background-80" - } duration-300 focus:border-custom-primary focus:outline-none focus:ring-1 focus:ring-custom-primary` + } duration-300` : "" } ${error ? "border-red-500 bg-red-100" : ""} ${ disabled ? "cursor-not-allowed" : "cursor-pointer" } ${ noBorder ? "" : "border border-custom-border-100" - } w-full rounded-md bg-transparent caret-transparent ${className}`} + } w-full rounded-md caret-transparent outline-none ${className}`} dateFormat="MMM dd, yyyy" isClearable={isClearable} disabled={disabled} diff --git a/apps/app/components/ui/empty-space.tsx b/apps/app/components/ui/empty-space.tsx index 8c09d4c16..f31280aeb 100644 --- a/apps/app/components/ui/empty-space.tsx +++ b/apps/app/components/ui/empty-space.tsx @@ -64,13 +64,13 @@ const EmptySpaceItem: React.FC = ({ title, description, Ico
-
-
{title}
- {description ?
{description}
: null} +
+
{title}
+ {description ?
{description}
: null}
diff --git a/apps/app/components/ui/multi-level-dropdown.tsx b/apps/app/components/ui/multi-level-dropdown.tsx index cf2371471..14f2a8106 100644 --- a/apps/app/components/ui/multi-level-dropdown.tsx +++ b/apps/app/components/ui/multi-level-dropdown.tsx @@ -43,7 +43,7 @@ export const MultiLevelDropdown: React.FC = ({
setOpenChildFor(null)} - className={`group flex items-center justify-between gap-2 rounded-md border border-custom-border-100 px-3 py-1.5 text-xs shadow-sm duration-300 focus:outline-none ${ + className={`group flex items-center justify-between gap-2 rounded-md border border-custom-border-100 px-3 py-1.5 text-xs shadow-sm duration-300 focus:outline-none hover:text-custom-text-100 hover:bg-custom-background-90 ${ open ? "bg-custom-background-90 text-custom-text-100" : "text-custom-text-200" }`} > diff --git a/apps/app/components/workspace/sidebar-dropdown.tsx b/apps/app/components/workspace/sidebar-dropdown.tsx index b44df4832..5629b782b 100644 --- a/apps/app/components/workspace/sidebar-dropdown.tsx +++ b/apps/app/components/workspace/sidebar-dropdown.tsx @@ -108,9 +108,9 @@ export const WorkspaceSidebarDropdown = () => {
{!sidebarCollapse && ( -

+

{activeWorkspace?.name ? truncateText(activeWorkspace.name, 14) : "Loading..."} -

+

)}
@@ -166,7 +166,13 @@ export const WorkspaceSidebarDropdown = () => { )} -
{truncateText(workspace.name, 18)}
+
+ {truncateText(workspace.name, 18)} +
{ setAnalyticsModal(true)} - className="!py-1.5 font-normal rounded-md text-custom-text-200" + className="!py-1.5 font-normal rounded-md text-custom-text-200 hover:text-custom-text-100" outline > Analytics diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/index.tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/index.tsx index 90075fe81..6f87366f8 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/index.tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/index.tsx @@ -63,7 +63,7 @@ const ProjectIssues: NextPage = () => { setAnalyticsModal(true)} - className="!py-1.5 rounded-md font-normal text-custom-sidebar-text-200 border-custom-sidebar-border-100 hover:bg-custom-sidebar-background-90" + className="!py-1.5 rounded-md font-normal text-custom-sidebar-text-200 border-custom-sidebar-border-100 hover:text-custom-text-100 hover:bg-custom-sidebar-background-90" outline > Analytics @@ -72,7 +72,7 @@ const ProjectIssues: NextPage = () => { Inbox diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx index bbfb9c2b9..98d2c7985 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx @@ -144,7 +144,7 @@ const SingleModule: React.FC = () => { setAnalyticsModal(true)} - className="!py-1.5 font-normal rounded-md text-custom-text-200" + className="!py-1.5 font-normal rounded-md text-custom-text-200 hover:text-custom-text-100" outline > Analytics diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/estimates.tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/estimates.tsx index 545b4f276..1415382e9 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/estimates.tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/estimates.tsx @@ -122,14 +122,14 @@ const EstimatesSettings: NextPage = () => { } > -
+

Estimates

- { setEstimateToUpdate(undefined); setEstimateFormOpen(true); @@ -137,7 +137,7 @@ const EstimatesSettings: NextPage = () => { > Create New Estimate - +
{projectDetails?.estimate && ( Disable Estimates )} @@ -146,7 +146,7 @@ const EstimatesSettings: NextPage = () => {
{estimatesList ? ( estimatesList.length > 0 ? ( -
+
{estimatesList.map((estimate) => ( { ))}
) : ( -
+
{ >
{feature.icon} -
+

{feature.title}

{feature.description}

@@ -219,11 +219,21 @@ const FeaturesSettings: NextPage = () => {
))}
-
- + diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx index d2acc3a5f..988c82198 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx @@ -58,11 +58,11 @@ const ProjectIntegrations: NextPage = () => { } > -
+
{workspaceIntegrations ? ( workspaceIntegrations.length > 0 ? ( -
+
{workspaceIntegrations.map((integration) => ( diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/states.tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/states.tsx index b6f61b95c..835b395f5 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/states.tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/states.tsx @@ -85,10 +85,10 @@ const StatesSettings: NextPage = () => { return (
-

{key}

+

{key}

- - - + const [alert, setAlert] = useState(false); -
- + const [upgradeModal, setUpgradeModal] = useState(false); + + const { data: workspaceDetails } = useSWR( + workspaceSlug ? WORKSPACE_DETAILS(workspaceSlug as string) : null, + workspaceSlug ? () => workspaceService.getWorkspace(workspaceSlug as string) : null + ); + const issueNumber = workspaceDetails?.total_issues || 0; + + return ( + <> + setUpgradeModal(false)} + user={user} + issueNumber={issueNumber} + /> + {!sidebarCollapse && (alert || (issueNumber && issueNumber >= 750)) ? ( + <>
= 750 + ? "bg-red-50 text-red-600 border-red-200" + : issueNumber >= 500 + ? "bg-yellow-50 text-yellow-600 border-yellow-200" + : "text-green-600" + }`} > - {helpOptions.map(({ name, Icon, href, onClick }) => { - if (href) - return ( - - + +
Free Plan
+ {issueNumber < 750 && ( +
setAlert(false)}> + +
+ )} +
+
+ This workspace has used {issueNumber} of its 1024 issues creation limit ( + {((issueNumber / 1024) * 100).toFixed(0)} + %). +
+
+ + ) : ( + "" + )} +
+ {alert || (issueNumber && issueNumber >= 750) ? ( + + ) : ( + + )} + + + + + + +
+ +
+ {helpOptions.map(({ name, Icon, href, onClick }) => { + if (href) + return ( + + + + {name} + + + ); + else + return ( + - ); - })} -
- + + ); + })} +
+ +
-
+ ); }; diff --git a/apps/app/components/workspace/upgrade-to-pro-modal.tsx b/apps/app/components/workspace/upgrade-to-pro-modal.tsx new file mode 100644 index 000000000..32d778fc0 --- /dev/null +++ b/apps/app/components/workspace/upgrade-to-pro-modal.tsx @@ -0,0 +1,248 @@ +import React, { useState, useEffect } from "react"; +// headless ui +import { Dialog, Transition } from "@headlessui/react"; +// icons +import { XCircleIcon, RocketLaunchIcon } from "@heroicons/react/24/outline"; +import { CheckCircleIcon } from "@heroicons/react/24/solid"; +// ui +import { CircularProgress } from "components/ui"; +// types +import type { ICurrentUserResponse, IWorkspace } from "types"; + +declare global { + interface Window { + supabase: any; + } +} + +type Props = { + isOpen: boolean; + onClose: () => void; + user: ICurrentUserResponse | undefined; + issueNumber: number; +}; + +const UpgradeToProModal: React.FC = ({ isOpen, onClose, user, issueNumber }) => { + const [supabaseClient, setSupabaseClient] = useState(null); + + useEffect(() => { + // Create a Supabase client + if (process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) { + const { createClient } = window.supabase; + const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, + { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + } + ); + + if (supabase) { + setSupabaseClient(supabase); + } + } + }, []); + + const [isLoading, setIsLoading] = useState(false); + + const handleClose = () => { + onClose(); + setIsLoading(false); + }; + + const proFeatures = [ + "Everything in free", + "Unlimited users", + "Unlimited file uploads", + "Priority Support", + "Custom Theming", + "Access to Roadmap", + "Plane AI (GPT unlimited)", + ]; + + const [errorMessage, setErrorMessage] = useState( + null + ); + const [loader, setLoader] = useState(false); + const submitEmail = async () => { + setLoader(true); + const payload = { email: user?.email || "" }; + + if (supabaseClient) { + if (payload?.email) { + const emailExists = await supabaseClient + .from("web-waitlist") + .select("id,email,count") + .eq("email", payload?.email); + if (emailExists.data.length === 0) { + const emailCreation = await supabaseClient + .from("web-waitlist") + .insert([{ email: payload?.email, count: 1, last_visited: new Date() }]) + .select("id,email,count"); + if (emailCreation.status === 201) + setErrorMessage({ status: "success", message: "Successfully registered." }); + else setErrorMessage({ status: "insert_error", message: "Insertion Error." }); + } else { + const emailCountUpdate = await supabaseClient + .from("web-waitlist") + .upsert({ + id: emailExists.data[0]?.id, + count: emailExists.data[0]?.count + 1, + last_visited: new Date(), + }) + .select("id,email,count"); + if (emailCountUpdate.status === 201) + setErrorMessage({ + status: "email_already_exists", + message: "Email already exists.", + }); + else setErrorMessage({ status: "update_error", message: "Update Error." }); + } + } else setErrorMessage({ status: "email_required", message: "Please provide email." }); + } else + setErrorMessage({ + status: "supabase_error", + message: "Network error. Please try again later.", + }); + + setLoader(false); + }; + + return ( + + + +
+ + +
+
+ + +
+
+
+
= 750 + ? "text-red-600" + : issueNumber >= 500 + ? "text-yellow-600" + : "text-green-600" + }`} + title="Shortcuts" + > + 100 ? 100 : (issueNumber / 1024) * 100 + } + /> +
+
+
Upgrade to pro
+
+ This workspace has used {issueNumber} of its 1024 issues creation limit ( + {((issueNumber / 1024) * 100).toFixed(2)}%). +
+
+
+ +
+
+
+
+ +
+
+
Order summary
+
+ Priority support, file uploads, and access to premium features. +
+ +
+ {proFeatures.map((feature, index) => ( +
+
+ +
+
{feature}
+
+ ))} +
+
+
+
+
+
+
Summary
+
+ +
+
+
+ Plane application is currently in dev-mode. We will soon introduce Pro plans + once general availability has been established. Stay tuned for more updates. + In the meantime, Plane remains free and unrestricted. +

+ We{"'"}ll ensure a smooth transition from the community version to the Pro + plan for you. +
+ + {errorMessage && ( +
+ {errorMessage?.message} +
+ )} +
+
+
+
+
+
+
+
+ ); +}; + +export default UpgradeToProModal; diff --git a/apps/app/pages/_document.tsx b/apps/app/pages/_document.tsx index 24f3068c9..8958de15a 100644 --- a/apps/app/pages/_document.tsx +++ b/apps/app/pages/_document.tsx @@ -13,6 +13,7 @@ class MyDocument extends Document {