diff --git a/.env.example b/.env.example index fc1aef49de6..2b69317616a 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/CONTRIBUTING.md b/CONTRIBUTING.md index 75ccb884c29..6baa0bb07e4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,7 +23,6 @@ You can open a new issue with this [issue form](https://github.com/makeplane/pla - Python version 3.8+ - Postgres version v14 - Redis version v6.2.7 -- pnpm version 7.22.0 ### Setup the project diff --git a/README.md b/README.md index 7e9422f1803..0e893ebe355 100644 --- a/README.md +++ b/README.md @@ -19,14 +19,14 @@

Plane Screens Plane Screens @@ -61,14 +61,6 @@ chmod +x setup.sh > If running in a cloud env replace localhost with public facing IP address of the VM -- Export Environment Variables - -```bash -set -a -source .env -set +a -``` - - Run Docker compose up ```bash @@ -94,7 +86,7 @@ docker compose up -d

Plane Views @@ -103,7 +95,7 @@ docker compose up -d

Plane Issue Details @@ -112,7 +104,7 @@ docker compose up -d

Plane Cycles and Modules @@ -121,7 +113,7 @@ docker compose up -d

Plane Analytics @@ -130,7 +122,7 @@ docker compose up -d

Plane Pages @@ -140,7 +132,7 @@ docker compose up -d

Plane Command Menu diff --git a/apiserver/Dockerfile.api b/apiserver/Dockerfile.api index 402940f83a1..15c3f53a92b 100644 --- a/apiserver/Dockerfile.api +++ b/apiserver/Dockerfile.api @@ -49,7 +49,7 @@ USER root RUN apk --no-cache add "bash~=5.2" COPY ./bin ./bin/ -RUN chmod +x ./bin/takeoff ./bin/worker +RUN chmod +x ./bin/takeoff ./bin/worker ./bin/beat RUN chmod -R 777 /code USER captain diff --git a/apiserver/Procfile b/apiserver/Procfile index 30d73491385..694c49df409 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/bin/beat b/apiserver/bin/beat new file mode 100644 index 00000000000..45d357442a9 --- /dev/null +++ b/apiserver/bin/beat @@ -0,0 +1,5 @@ +#!/bin/bash +set -e + +python manage.py wait_for_db +celery -A plane beat -l info \ No newline at end of file diff --git a/apiserver/bin/takeoff b/apiserver/bin/takeoff index d22eceb6ee9..dc25a14e2d1 100755 --- a/apiserver/bin/takeoff +++ b/apiserver/bin/takeoff @@ -6,4 +6,4 @@ python manage.py migrate # Create a Default User python bin/user_script.py -exec gunicorn -w 8 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --config gunicorn.config.py --max-requests 1200 --max-requests-jitter 1000 --access-logfile - +exec gunicorn -w 8 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --max-requests 1200 --max-requests-jitter 1000 --access-logfile - diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 2b72c5ae1fb..2ff210f98ca 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -21,6 +21,7 @@ ProjectIdentifierSerializer, ProjectFavoriteSerializer, ProjectLiteSerializer, + ProjectMemberLiteSerializer, ) from .state import StateSerializer, StateLiteSerializer from .view import IssueViewSerializer, IssueViewFavoriteSerializer @@ -41,6 +42,7 @@ IssueLinkSerializer, IssueLiteSerializer, IssueAttachmentSerializer, + IssueSubscriberSerializer, ) from .module import ( @@ -74,4 +76,7 @@ ) 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 14782dbe5cd..7aeee7d7074 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -19,6 +19,7 @@ IssueProperty, IssueBlocker, IssueAssignee, + IssueSubscriber, IssueLabel, Label, IssueBlocker, @@ -461,9 +462,9 @@ class Meta: # Issue Serializer with state details class IssueStateSerializer(BaseSerializer): - state_detail = StateSerializer(read_only=True, source="state") - project_detail = ProjectSerializer(read_only=True, source="project") - label_details = LabelSerializer(read_only=True, source="labels", many=True) + label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) + state_detail = StateLiteSerializer(read_only=True, source="state") + project_detail = ProjectLiteSerializer(read_only=True, source="project") assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) sub_issues_count = serializers.IntegerField(read_only=True) bridge_id = serializers.UUIDField(read_only=True) @@ -476,7 +477,7 @@ class Meta: class IssueSerializer(BaseSerializer): - project_detail = ProjectSerializer(read_only=True, source="project") + project_detail = ProjectLiteSerializer(read_only=True, source="project") state_detail = StateSerializer(read_only=True, source="state") parent_detail = IssueFlatSerializer(read_only=True, source="parent") label_details = LabelSerializer(read_only=True, source="labels", many=True) @@ -530,3 +531,14 @@ class Meta: "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/module.py b/apiserver/plane/api/serializers/module.py index ea9edd82c9f..a82a0f39f1d 100644 --- a/apiserver/plane/api/serializers/module.py +++ b/apiserver/plane/api/serializers/module.py @@ -106,7 +106,7 @@ class Meta: class ModuleIssueSerializer(BaseSerializer): module_detail = ModuleFlatSerializer(read_only=True, source="module") - issue_detail = IssueStateSerializer(read_only=True, source="issue") + issue_detail = ProjectLiteSerializer(read_only=True, source="issue") sub_issues_count = serializers.IntegerField(read_only=True) class Meta: @@ -151,7 +151,7 @@ def create(self, validated_data): class ModuleSerializer(BaseSerializer): - project_detail = ProjectSerializer(read_only=True, source="project") + project_detail = ProjectLiteSerializer(read_only=True, source="project") lead_detail = UserLiteSerializer(read_only=True, source="lead") members_detail = UserLiteSerializer(read_only=True, many=True, source="members") link_module = ModuleLinkSerializer(read_only=True, many=True) diff --git a/apiserver/plane/api/serializers/notification.py b/apiserver/plane/api/serializers/notification.py new file mode 100644 index 00000000000..b6a4f3e4a0d --- /dev/null +++ b/apiserver/plane/api/serializers/notification.py @@ -0,0 +1,12 @@ +# Module imports +from .base import BaseSerializer +from .user import UserLiteSerializer +from plane.db.models import Notification + +class NotificationSerializer(BaseSerializer): + triggered_by_details = UserLiteSerializer(read_only=True, source="triggered_by") + + class Meta: + model = Notification + fields = "__all__" + diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py index 18ee19e7bc4..641edb07ca4 100644 --- a/apiserver/plane/api/serializers/project.py +++ b/apiserver/plane/api/serializers/project.py @@ -77,6 +77,13 @@ def update(self, instance, validated_data): raise serializers.ValidationError(detail="Project Identifier is already taken") +class ProjectLiteSerializer(BaseSerializer): + class Meta: + model = Project + fields = ["id", "identifier", "name"] + read_only_fields = fields + + class ProjectDetailSerializer(BaseSerializer): workspace = WorkSpaceSerializer(read_only=True) default_assignee = UserLiteSerializer(read_only=True) @@ -94,7 +101,7 @@ class Meta: class ProjectMemberSerializer(BaseSerializer): workspace = WorkSpaceSerializer(read_only=True) - project = ProjectSerializer(read_only=True) + project = ProjectLiteSerializer(read_only=True) member = UserLiteSerializer(read_only=True) class Meta: @@ -103,8 +110,8 @@ class Meta: class ProjectMemberInviteSerializer(BaseSerializer): - project = ProjectSerializer(read_only=True) - workspace = WorkSpaceSerializer(read_only=True) + project = ProjectLiteSerializer(read_only=True) + workspace = WorkspaceLiteSerializer(read_only=True) class Meta: model = ProjectMemberInvite @@ -118,7 +125,7 @@ class Meta: class ProjectFavoriteSerializer(BaseSerializer): - project_detail = ProjectSerializer(source="project", read_only=True) + project_detail = ProjectLiteSerializer(source="project", read_only=True) class Meta: model = ProjectFavorite @@ -129,8 +136,13 @@ class Meta: ] -class ProjectLiteSerializer(BaseSerializer): + + +class ProjectMemberLiteSerializer(BaseSerializer): + member = UserLiteSerializer(read_only=True) + is_subscribed = serializers.BooleanField(read_only=True) + class Meta: - model = Project - fields = ["id", "identifier", "name"] + 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 806ebcd6fed..04bbc2a47bc 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -22,6 +22,7 @@ # User UserEndpoint, UpdateUserOnBoardedEndpoint, + UpdateUserTourCompletedEndpoint, UserActivityEndpoint, ## End User # Workspaces @@ -76,6 +77,8 @@ IssueLinkViewSet, BulkCreateIssueLabelsEndpoint, IssueAttachmentEndpoint, + IssueArchiveViewSet, + IssueSubscriberViewSet, ## End Issues # States StateViewSet, @@ -148,6 +151,10 @@ ExportAnalyticsEndpoint, DefaultAnalyticsEndpoint, ## End Analytics + # Notification + NotificationViewSet, + UnreadNotificationEndpoint, + ## End Notification ) @@ -197,7 +204,12 @@ 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 +479,6 @@ "workspaces//user-favorite-projects/", ProjectFavoritesViewSet.as_view( { - "get": "list", "post": "create", } ), @@ -797,6 +808,34 @@ 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 +860,36 @@ 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 +1342,51 @@ 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", + ), + path( + "workspaces//users/notifications/unread/", + UnreadNotificationEndpoint.as_view(), + name="unread-notifications", + ), + ## End Notification ] diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index f8d170532be..076cdd0069e 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -16,6 +16,7 @@ from .people import ( UserEndpoint, UpdateUserOnBoardedEndpoint, + UpdateUserTourCompletedEndpoint, UserActivityEndpoint, ) @@ -65,6 +66,8 @@ IssueLinkViewSet, BulkCreateIssueLabelsEndpoint, IssueAttachmentEndpoint, + IssueArchiveViewSet, + IssueSubscriberViewSet, ) from .auth_extended import ( @@ -133,6 +136,7 @@ from .release import ReleaseNotesEndpoint from .inbox import InboxViewSet, InboxIssueViewSet + from .analytic import ( AnalyticsEndpoint, AnalyticViewViewset, @@ -140,3 +144,5 @@ ExportAnalyticsEndpoint, DefaultAnalyticsEndpoint, ) + +from .notification import NotificationViewSet, UnreadNotificationEndpoint \ No newline at end of file diff --git a/apiserver/plane/api/views/authentication.py b/apiserver/plane/api/views/authentication.py index 068fae5a97c..0d37b1c33bb 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/cycle.py b/apiserver/plane/api/views/cycle.py index 0dd5d67d09f..d7833352873 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -706,9 +706,6 @@ def post(self, request, slug, project_id): class CycleFavoriteViewSet(BaseViewSet): - permission_classes = [ - ProjectEntityPermission, - ] serializer_class = CycleFavoriteSerializer model = CycleFavorite diff --git a/apiserver/plane/api/views/gpt.py b/apiserver/plane/api/views/gpt.py index a48bea242da..f8065f6d04b 100644 --- a/apiserver/plane/api/views/gpt.py +++ b/apiserver/plane/api/views/gpt.py @@ -30,31 +30,6 @@ def post(self, request, slug, project_id): status=status.HTTP_400_BAD_REQUEST, ) - count = 0 - - # If logger is enabled check for request limit - if settings.LOGGER_BASE_URL: - try: - headers = { - "Content-Type": "application/json", - } - - response = requests.post( - settings.LOGGER_BASE_URL, - json={"user_id": str(request.user.id)}, - headers=headers, - ) - count = response.json().get("count", 0) - if not response.json().get("success", False): - return Response( - { - "error": "You have surpassed the monthly limit for AI assistance" - }, - status=status.HTTP_429_TOO_MANY_REQUESTS, - ) - except Exception as e: - capture_exception(e) - prompt = request.data.get("prompt", False) task = request.data.get("task", False) @@ -67,7 +42,7 @@ def post(self, request, slug, project_id): 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, @@ -82,7 +57,6 @@ def post(self, request, slug, project_id): { "response": text, "response_html": text_html, - "count": count, "project_detail": ProjectLiteSerializer(project).data, "workspace_detail": WorkspaceLiteSerializer(workspace).data, }, diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 36b3411fcfa..aab926fd2c7 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -15,6 +15,7 @@ Value, CharField, When, + Exists, Max, ) from django.core.serializers.json import DjangoJSONEncoder @@ -43,11 +44,14 @@ IssueLinkSerializer, IssueLiteSerializer, IssueAttachmentSerializer, + IssueSubscriberSerializer, + ProjectMemberLiteSerializer, ) from plane.api.permissions import ( ProjectEntityPermission, WorkSpaceAdminPermission, ProjectMemberPermission, + ProjectLitePermission, ) from plane.db.models import ( Project, @@ -59,6 +63,8 @@ IssueLink, IssueAttachment, State, + IssueSubscriber, + ProjectMember, ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.grouper import group_results @@ -162,8 +168,8 @@ def list(self, request, slug, project_id): issue_queryset = ( self.get_queryset() .filter(**filters) - .annotate(cycle_id=F("issue_cycle__id")) - .annotate(module_id=F("issue_module__id")) + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -256,7 +262,7 @@ def list(self, request, slug, project_id): return Response(issues, status=status.HTTP_200_OK) except Exception as e: - capture_exception(e) + print(e) return Response( {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, @@ -905,3 +911,347 @@ def get(self, request, slug, project_id, issue_id): {"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__cycle_id")) + .annotate(module_id=F("issue_module__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/module.py b/apiserver/plane/api/views/module.py index 5a235ba8f34..2a7532ecf68 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -480,9 +480,6 @@ def get_queryset(self): class ModuleFavoriteViewSet(BaseViewSet): - permission_classes = [ - ProjectEntityPermission, - ] serializer_class = ModuleFavoriteSerializer model = ModuleFavorite diff --git a/apiserver/plane/api/views/notification.py b/apiserver/plane/api/views/notification.py new file mode 100644 index 00000000000..b7f5bd33587 --- /dev/null +++ b/apiserver/plane/api/views/notification.py @@ -0,0 +1,258 @@ +# 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, BaseAPIView +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", "project," "triggered_by", "receiver") + ) + + def list(self, request, slug): + try: + snoozed = request.GET.get("snoozed", "false") + archived = request.GET.get("archived", "false") + read = request.GET.get("read", "true") + + # Filter type + type = request.GET.get("type", "all") + + notifications = Notification.objects.filter( + workspace__slug=slug, receiver_id=request.user.id + ).order_by("snoozed_till", "-created_at") + + # 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 == "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, + ) + + +class UnreadNotificationEndpoint(BaseAPIView): + def get(self, request, slug): + try: + # Watching Issues Count + watching_issues_count = Notification.objects.filter( + workspace__slug=slug, + receiver_id=request.user.id, + read_at__isnull=True, + archived_at__isnull=True, + entity_identifier__in=IssueSubscriber.objects.filter( + workspace__slug=slug, subscriber_id=request.user.id + ).values_list("issue_id", flat=True), + ).count() + + # My Issues Count + my_issues_count = Notification.objects.filter( + workspace__slug=slug, + receiver_id=request.user.id, + read_at__isnull=True, + archived_at__isnull=True, + entity_identifier__in=IssueAssignee.objects.filter( + workspace__slug=slug, assignee_id=request.user.id + ).values_list("issue_id", flat=True), + ).count() + + # Created Issues Count + created_issues_count = Notification.objects.filter( + workspace__slug=slug, + receiver_id=request.user.id, + read_at__isnull=True, + archived_at__isnull=True, + entity_identifier__in=Issue.objects.filter( + workspace__slug=slug, created_by=request.user + ).values_list("pk", flat=True), + ).count() + + return Response( + { + "watching_issues": watching_issues_count, + "my_issues": my_issues_count, + "created_issues": created_issues_count, + }, + 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/people.py b/apiserver/plane/api/views/people.py index 8e19fea1a6c..705f5c96e00 100644 --- a/apiserver/plane/api/views/people.py +++ b/apiserver/plane/api/views/people.py @@ -37,7 +37,9 @@ def retrieve(self, request): 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 @@ def retrieve(self, request): "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 @@ def retrieve(self, request): 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 @@ def retrieve(self, request): 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 @@ def patch(self, request): ) +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 68a34ab48a5..5c6ea3fd157 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -96,6 +96,7 @@ def get_queryset(self): 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 @@ def list(self, request, slug): .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) @@ -153,32 +160,32 @@ def create(self, request, slug): states = [ { "name": "Backlog", - "color": "#5e6ad2", + "color": "#A3A3A3", "sequence": 15000, "group": "backlog", "default": True, }, { "name": "Todo", - "color": "#eb5757", + "color": "#3A3A3A", "sequence": 25000, "group": "unstarted", }, { "name": "In Progress", - "color": "#26b5ce", + "color": "#F59E0B", "sequence": 35000, "group": "started", }, { "name": "Done", - "color": "#f2c94c", + "color": "#16A34A", "sequence": 45000, "group": "completed", }, { "name": "Cancelled", - "color": "#4cb782", + "color": "#EF4444", "sequence": 55000, "group": "cancelled", }, @@ -259,7 +266,7 @@ def partial_update(self, request, slug, pk=None): group="backlog", description="Default state for managing all Inbox Issues", project_id=pk, - color="#ff7700" + color="#ff7700", ) return Response(serializer.data, status=status.HTTP_200_OK) @@ -550,45 +557,47 @@ class AddMemberToProjectEndpoint(BaseAPIView): def post(self, request, slug, project_id): try: - member_id = request.data.get("member_id", False) - role = request.data.get("role", False) + members = request.data.get("members", []) - if not member_id or not role: - return Response( - {"error": "Member ID and role is required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Check if the user is a member in the workspace - if not WorkspaceMember.objects.filter( - workspace__slug=slug, member_id=member_id - ).exists(): - # TODO: Update this error message - nk - return Response( - { - "error": "User is not a member of the workspace. Invite the user to the workspace to add him to project" - }, - status=status.HTTP_400_BAD_REQUEST, - ) + # get the project + project = Project.objects.get(pk=project_id, workspace__slug=slug) - # Check if the user is already member of project - if ProjectMember.objects.filter( - project=project_id, member_id=member_id - ).exists(): + if not len(members): return Response( - {"error": "User is already a member of the project"}, + {"error": "Atleast one member is required"}, status=status.HTTP_400_BAD_REQUEST, ) - # Add the user to project - project_member = ProjectMember.objects.create( - project_id=project_id, member_id=member_id, role=role + project_members = ProjectMember.objects.bulk_create( + [ + ProjectMember( + member_id=member.get("member_id"), + role=member.get("role", 10), + project_id=project_id, + workspace_id=project.workspace_id, + ) + for member in members + ], + batch_size=10, + ignore_conflicts=True, ) - serializer = ProjectMemberSerializer(project_member) + serializer = ProjectMemberSerializer(project_members, many=True) return Response(serializer.data, status=status.HTTP_201_CREATED) - + except KeyError: + return Response( + {"error": "Incorrect data sent"}, status=status.HTTP_400_BAD_REQUEST + ) + except Project.DoesNotExist: + return Response( + {"error": "Project does not exist"}, status=status.HTTP_400_BAD_REQUEST + ) + except IntegrityError: + return Response( + {"error": "User not member of the workspace"}, + status=status.HTTP_400_BAD_REQUEST, + ) except Exception as e: capture_exception(e) return Response( diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index 9db9033e1c5..305deb525f2 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -3,6 +3,7 @@ from datetime import date, datetime from dateutil.relativedelta import relativedelta from uuid import uuid4 + # Django imports from django.db import IntegrityError from django.db.models import Prefetch @@ -94,14 +95,34 @@ def get_queryset(self): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - return self.filter_queryset( - super().get_queryset().select_related("owner") - ).order_by("name").filter(workspace_member__member=self.request.user).annotate(total_members=member_count).annotate(total_issues=issue_count) + return ( + self.filter_queryset(super().get_queryset().select_related("owner")) + .order_by("name") + .filter(workspace_member__member=self.request.user) + .annotate(total_members=member_count) + .annotate(total_issues=issue_count) + .select_related("owner") + ) def create(self, request): try: serializer = WorkSpaceSerializer(data=request.data) + slug = request.data.get("slug", False) + name = request.data.get("name", False) + + if not name or not slug: + return Response( + {"error": "Both name and slug are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if len(name) > 80 or len(slug) > 48: + return Response( + {"error": "The maximum length for name is 80 and for slug is 48"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if serializer.is_valid(): serializer.save(owner=request.user) # Create Workspace member @@ -161,14 +182,20 @@ def get(self, request): ) workspace = ( - Workspace.objects.prefetch_related( - Prefetch("workspace_member", queryset=WorkspaceMember.objects.all()) - ) - .filter( - workspace_member__member=request.user, + ( + Workspace.objects.prefetch_related( + Prefetch( + "workspace_member", queryset=WorkspaceMember.objects.all() + ) + ) + .filter( + workspace_member__member=request.user, + ) + .select_related("owner") ) - .select_related("owner") - ).annotate(total_members=member_count).annotate(total_issues=issue_count) + .annotate(total_members=member_count) + .annotate(total_issues=issue_count) + ) serializer = WorkSpaceSerializer(self.filter_queryset(workspace), many=True) return Response(serializer.data, status=status.HTTP_200_OK) @@ -217,9 +244,20 @@ def post(self, request, slug): ) # check for role level - requesting_user = WorkspaceMember.objects.get(workspace__slug=slug, member=request.user) - if len([email for email in emails if int(email.get("role", 10)) > requesting_user.role]): - return Response({"error": "You cannot invite a user with higher role"}, status=status.HTTP_400_BAD_REQUEST) + requesting_user = WorkspaceMember.objects.get( + workspace__slug=slug, member=request.user + ) + if len( + [ + email + for email in emails + if int(email.get("role", 10)) > requesting_user.role + ] + ): + return Response( + {"error": "You cannot invite a user with higher role"}, + status=status.HTTP_400_BAD_REQUEST, + ) workspace = Workspace.objects.get(slug=slug) @@ -894,7 +932,9 @@ def get(self, request, slug): ) state_distribution = ( - Issue.issue_objects.filter(workspace__slug=slug, assignees__in=[request.user]) + Issue.issue_objects.filter( + workspace__slug=slug, assignees__in=[request.user] + ) .annotate(state_group=F("state__group")) .values("state_group") .annotate(state_count=Count("state_group")) diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 5865a598237..105a05b5695 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -5,6 +5,7 @@ # 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 @@ 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,27 @@ def issue_activity( actor = User.objects.get(pk=actor_id) project = Project.objects.get(pk=project_id) + if type not in [ + "cycle.activity.created", + "cycle.activity.deleted", + "module.activity.created", + "module.activity.deleted", + ]: + issue = Issue.objects.filter(pk=issue_id, project_id=project_id).first() + + if issue is not None: + issue.updated_at = timezone.now() + issue.save(update_fields=["updated_at"]) + + 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 +1083,97 @@ 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: + 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) + + if type not in [ + "cycle.activity.created", + "cycle.activity.deleted", + "module.activity.created", + "module.activity.deleted", + ]: + # 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 + + issue = Issue.objects.filter(pk=issue_id, project_id=project_id).first() + + # Add bot filtering + if ( + issue is not None + and issue.created_by_id is not None + and not issue.created_by.is_bot + and str(issue.created_by_id) != str(actor_id) + ): + issue_subscribers = issue_subscribers + [issue.created_by_id] + + for subscriber in issue_subscribers: 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, + 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": { + "id": str(issue_activity.id), + "verb": str(issue_activity.verb), + "field": str(issue_activity.field), + "actor": str(issue_activity.actor_id), + "new_value": str(issue_activity.new_value), + "old_value": str(issue_activity.old_value), + "issue_comment": str( + issue_activity.issue_comment.comment_stripped + if issue_activity.issue_comment is not None + else "" + ), + }, + }, + ) ) + + # 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 00000000000..0e3ead65ddf --- /dev/null +++ b/apiserver/plane/bgtasks/issue_automation_task.py @@ -0,0 +1,157 @@ +# 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) + ), + ).filter( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True) + ) + + # 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": str(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) + ), + ).filter( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True) + ) + + # 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": str(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 1fbbdd732bd..ed0dc419eae 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 @@ # 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/migrations/0035_auto_20230704_2225.py b/apiserver/plane/db/migrations/0035_auto_20230704_2225.py new file mode 100644 index 00000000000..dec6265e615 --- /dev/null +++ b/apiserver/plane/db/migrations/0035_auto_20230704_2225.py @@ -0,0 +1,42 @@ +# Generated by Django 3.2.19 on 2023-07-04 16:55 + +from django.db import migrations, models + + +def update_company_organization_size(apps, schema_editor): + Model = apps.get_model("db", "Workspace") + updated_size = [] + for obj in Model.objects.all(): + obj.organization_size = str(obj.company_size) + updated_size.append(obj) + + Model.objects.bulk_update(updated_size, ["organization_size"], batch_size=100) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0034_auto_20230628_1046"), + ] + + operations = [ + migrations.AddField( + model_name="workspace", + name="organization_size", + field=models.CharField(default="2-10", max_length=20), + ), + migrations.RunPython(update_company_organization_size), + migrations.AlterField( + model_name="workspace", + name="name", + field=models.CharField(max_length=80, verbose_name="Workspace Name"), + ), + migrations.AlterField( + model_name="workspace", + name="slug", + field=models.SlugField(max_length=48, unique=True), + ), + migrations.RemoveField( + model_name="workspace", + name="company_size", + ), + ] diff --git a/apiserver/plane/db/migrations/0036_alter_workspace_organization_size.py b/apiserver/plane/db/migrations/0036_alter_workspace_organization_size.py new file mode 100644 index 00000000000..0b182f50b77 --- /dev/null +++ b/apiserver/plane/db/migrations/0036_alter_workspace_organization_size.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.19 on 2023-07-05 07:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0035_auto_20230704_2225'), + ] + + operations = [ + migrations.AlterField( + model_name='workspace', + name='organization_size', + field=models.CharField(max_length=20), + ), + ] diff --git a/apiserver/plane/db/migrations/0037_issue_archived_at_project_archive_in_and_more.py b/apiserver/plane/db/migrations/0037_issue_archived_at_project_archive_in_and_more.py new file mode 100644 index 00000000000..d11e1afd83f --- /dev/null +++ b/apiserver/plane/db/migrations/0037_issue_archived_at_project_archive_in_and_more.py @@ -0,0 +1,266 @@ +# Generated by Django 4.2.3 on 2023-07-19 06:52 + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import plane.db.models.user +import uuid + + + +def onboarding_default_steps(apps, schema_editor): + default_onboarding_schema = { + "workspace_join": True, + "profile_complete": True, + "workspace_create": True, + "workspace_invite": True, + } + + Model = apps.get_model("db", "User") + updated_user = [] + for obj in Model.objects.filter(is_onboarded=True): + obj.onboarding_step = default_onboarding_schema + obj.is_tour_completed = True + updated_user.append(obj) + + Model.objects.bulk_update(updated_user, ["onboarding_step", "is_tour_completed"], batch_size=100) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0036_alter_workspace_organization_size"), + ] + + operations = [ + migrations.AddField( + model_name="issue", + name="archived_at", + field=models.DateField(null=True), + ), + migrations.AddField( + model_name="project", + name="archive_in", + field=models.IntegerField( + default=0, + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(12), + ], + ), + ), + migrations.AddField( + model_name="project", + name="close_in", + field=models.IntegerField( + default=0, + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(12), + ], + ), + ), + migrations.AddField( + model_name="project", + name="default_state", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="default_state", + to="db.state", + ), + ), + migrations.AddField( + model_name="user", + name="is_tour_completed", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="user", + name="onboarding_step", + field=models.JSONField(default=plane.db.models.user.get_default_onboarding), + ), + migrations.RunPython(onboarding_default_steps), + migrations.CreateModel( + name="Notification", + fields=[ + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=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)), + ("read_at", models.DateTimeField(null=True)), + ("snoozed_till", models.DateTimeField(null=True)), + ("archived_at", models.DateTimeField(null=True)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to="db.project", + ), + ), + ( + "receiver", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="received_notifications", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "triggered_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="triggered_notifications", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Notification", + "verbose_name_plural": "Notifications", + "db_table": "notifications", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="IssueSubscriber", + fields=[ + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_subscribers", + to="db.issue", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "subscriber", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_subscribers", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Issue Subscriber", + "verbose_name_plural": "Issue Subscribers", + "db_table": "issue_subscribers", + "ordering": ("-created_at",), + "unique_together": {("issue", "subscriber")}, + }, + ), + ] diff --git a/apiserver/plane/db/migrations/0038_auto_20230720_1505.py b/apiserver/plane/db/migrations/0038_auto_20230720_1505.py new file mode 100644 index 00000000000..1f5c63a89c9 --- /dev/null +++ b/apiserver/plane/db/migrations/0038_auto_20230720_1505.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.3 on 2023-07-20 09:35 + +from django.db import migrations, models + + +def restructure_theming(apps, schema_editor): + Model = apps.get_model("db", "User") + updated_user = [] + for obj in Model.objects.exclude(theme={}).all(): + current_theme = obj.theme + updated_theme = { + "primary": current_theme.get("accent", ""), + "background": current_theme.get("bgBase", ""), + "sidebarBackground": current_theme.get("sidebar", ""), + "text": current_theme.get("textBase", ""), + "sidebarText": current_theme.get("textBase", ""), + "palette": f"""{current_theme.get("bgBase","")},{current_theme.get("textBase", "")},{current_theme.get("accent", "")},{current_theme.get("sidebar","")},{current_theme.get("textBase", "")}""", + "darkPalette": current_theme.get("darkPalette", "") + } + obj.theme = updated_theme + updated_user.append(obj) + + Model.objects.bulk_update( + updated_user, ["theme"], batch_size=100 + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0037_issue_archived_at_project_archive_in_and_more"), + ] + + operations = [ + migrations.RunPython(restructure_theming) + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 96c649a838c..1c075478dd7 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -33,6 +33,7 @@ IssueLink, IssueSequence, IssueAttachment, + IssueSubscriber, ) from .asset import FileAsset @@ -66,4 +67,7 @@ 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 1ecad6424ba..3e9da02d5c3 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -28,6 +28,7 @@ def get_queryset(self): | models.Q(issue_inbox__status=2) | models.Q(issue_inbox__isnull=True) ) + .exclude(archived_at__isnull=False) ) @@ -81,6 +82,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 +403,27 @@ class Meta: 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 00000000000..3df93571802 --- /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 0b6c4b50da0..b28cbc69e90 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -4,6 +4,7 @@ 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 b0ab7215977..36b3a1f6b04 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -18,6 +18,13 @@ 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/db/models/workspace.py b/apiserver/plane/db/models/workspace.py index b00d530132d..9b9fbb68c47 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -15,15 +15,15 @@ class Workspace(BaseModel): - name = models.CharField(max_length=255, verbose_name="Workspace Name") + name = models.CharField(max_length=80, verbose_name="Workspace Name") logo = models.URLField(verbose_name="Logo", blank=True, null=True) owner = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="owner_workspace", ) - slug = models.SlugField(max_length=100, db_index=True, unique=True) - company_size = models.PositiveIntegerField(default=10) + slug = models.SlugField(max_length=48, db_index=True, unique=True) + organization_size = models.CharField(max_length=20) def __str__(self): """Return name of the Workspace""" diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 2e026615929..e3a918c18a1 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -35,6 +35,7 @@ "rest_framework_simplejwt.token_blacklist", "corsheaders", "taggit", + "django_celery_beat", ] MIDDLEWARE = [ @@ -213,3 +214,4 @@ 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 1b862c01350..9d293c0191e 100644 --- a/apiserver/plane/settings/local.py +++ b/apiserver/plane/settings/local.py @@ -10,16 +10,14 @@ 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" DATABASES = { "default": { - "ENGINE": "django.db.backends.postgresql_psycopg2", + "ENGINE": "django.db.backends.postgresql", "NAME": os.environ.get("PGUSER", "plane"), "USER": "", "PASSWORD": "", @@ -27,13 +25,11 @@ } } -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() @@ -63,7 +59,29 @@ send_default_pii=True, environment="local", 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 @@ -82,8 +100,9 @@ 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) @@ -94,4 +113,4 @@ 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 29b75fc8bf0..2f2104397eb 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -13,13 +13,11 @@ from .common import * # noqa # Database -DEBUG = int(os.environ.get( - "DEBUG", 0 -)) == 1 +DEBUG = int(os.environ.get("DEBUG", 0)) == 1 DATABASES = { "default": { - "ENGINE": "django.db.backends.postgresql_psycopg2", + "ENGINE": "django.db.backends.postgresql", "NAME": "plane", "USER": os.environ.get("PGUSER", ""), "PASSWORD": os.environ.get("PGPASSWORD", ""), @@ -72,8 +70,12 @@ ] CORS_ALLOW_CREDENTIALS = True -# Simplified static file serving. -STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" + +STORAGES = { + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, +} if bool(os.environ.get("SENTRY_DSN", False)): sentry_sdk.init( @@ -84,11 +86,12 @@ traces_sample_rate=1, send_default_pii=True, environment="production", + profiles_sample_rate=1.0, ) if DOCKERIZED and USE_MINIO: INSTALLED_APPS += ("storages",) - DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" + STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"} # The AWS access key to use. AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key") # The AWS secret access key to use. @@ -96,7 +99,9 @@ # The name of the bucket to store files in. AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads") # The full URL to the S3 endpoint. Leave blank to use the default region URL. - AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "http://plane-minio:9000") + AWS_S3_ENDPOINT_URL = os.environ.get( + "AWS_S3_ENDPOINT_URL", "http://plane-minio:9000" + ) # Default permissions AWS_DEFAULT_ACL = "public-read" AWS_QUERYSTRING_AUTH = False @@ -187,7 +192,10 @@ # extra characters appended. AWS_S3_FILE_OVERWRITE = False - DEFAULT_FILE_STORAGE = "django_s3_storage.storage.S3Storage" + STORAGES["default"] = { + "BACKEND": "django_s3_storage.storage.S3Storage", + } + # AWS Settings End # Enable Connection Pooling (if desired) @@ -202,9 +210,6 @@ ] -# Simplified static file serving. -STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" - SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True @@ -241,8 +246,9 @@ 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 11ff7a3721d..5e274f8f32e 100644 --- a/apiserver/plane/settings/staging.py +++ b/apiserver/plane/settings/staging.py @@ -11,13 +11,12 @@ 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", + "ENGINE": "django.db.backends.postgresql", "NAME": os.environ.get("PGUSER", "plane"), "USER": "", "PASSWORD": "", @@ -48,13 +47,15 @@ # TODO: Make it FALSE and LIST DOMAINS IN FULL PROD. CORS_ALLOW_ALL_ORIGINS = True -# Simplified static file serving. -STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" +STORAGES = { + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, +} + # 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 @@ -66,6 +67,7 @@ traces_sample_rate=1, send_default_pii=True, environment="staging", + profiles_sample_rate=1.0, ) # The AWS region to connect to. @@ -150,7 +152,9 @@ AWS_S3_FILE_OVERWRITE = False # AWS Settings End - +STORAGES["default"] = { + "BACKEND": "django_s3_storage.storage.S3Storage", +} # Enable Connection Pooling (if desired) # DATABASES['default']['ENGINE'] = 'django_postgrespool' @@ -163,11 +167,6 @@ "*", ] - -DEFAULT_FILE_STORAGE = "django_s3_storage.storage.S3Storage" -# Simplified static file serving. -STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" - SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True @@ -199,15 +198,19 @@ 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/plane/urls.py b/apiserver/plane/urls.py index a2244ffe07d..2b83ef8cf25 100644 --- a/apiserver/plane/urls.py +++ b/apiserver/plane/urls.py @@ -3,11 +3,10 @@ """ # from django.contrib import admin -from django.urls import path +from django.urls import path, include, re_path from django.views.generic import TemplateView from django.conf import settings -from django.conf.urls import include, url, static # from django.conf.urls.static import static @@ -18,11 +17,10 @@ path("", include("plane.web.urls")), ] -urlpatterns = urlpatterns + static.static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) if settings.DEBUG: import debug_toolbar urlpatterns = [ - url(r"^__debug__/", include(debug_toolbar.urls)), + re_path(r"^__debug__/", include(debug_toolbar.urls)), ] + urlpatterns diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py index 74acb2044d7..6a9e8b8e868 100644 --- a/apiserver/plane/utils/issue_filters.py +++ b/apiserver/plane/utils/issue_filters.py @@ -166,16 +166,16 @@ def filter_target_date(params, filter, method): for query in target_dates: target_date_query = query.split(";") if len(target_date_query) == 2 and "after" in target_date_query: - filter["target_date__gte"] = target_date_query[0] + filter["target_date__gt"] = target_date_query[0] else: - filter["target_date__lte"] = target_date_query[0] + filter["target_date__lt"] = target_date_query[0] else: if params.get("target_date", None) and len(params.get("target_date")): for query in params.get("target_date"): if query.get("timeline", "after") == "after": - filter["target_date__gte"] = query.get("datetime") + filter["target_date__gt"] = query.get("datetime") else: - filter["target_date__lte"] = query.get("datetime") + filter["target_date__lt"] = query.get("datetime") return filter diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 2bc10996801..3421d9bb157 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -1,31 +1,34 @@ # base requirements -Django==3.2.19 +Django==4.2.3 django-braces==1.15.0 -django-taggit==3.1.0 -psycopg2==2.9.5 -django-oauth-toolkit==2.2.0 -mistune==2.0.4 +django-taggit==4.0.0 +psycopg==3.1.9 +django-oauth-toolkit==2.3.0 +mistune==3.0.1 djangorestframework==3.14.0 -redis==4.5.4 +redis==4.6.0 django-nested-admin==4.0.2 -django-cors-headers==3.13.0 -whitenoise==6.3.0 -django-allauth==0.52.0 -faker==13.4.0 -django-filter==22.1 +django-cors-headers==4.1.0 +whitenoise==6.5.0 +django-allauth==0.54.0 +faker==18.11.2 +django-filter==23.2 jsonmodels==2.6.0 djangorestframework-simplejwt==5.2.2 -sentry-sdk==1.14.0 -django-s3-storage==0.13.11 +sentry-sdk==1.27.0 +django-s3-storage==0.14.0 django-crum==0.7.9 django-guardian==2.4.0 dj_rest_auth==2.2.5 -google-auth==2.16.0 -google-api-python-client==2.75.0 -django-redis==5.2.0 -uvicorn==0.20.0 +google-auth==2.21.0 +google-api-python-client==2.92.0 +django-redis==5.3.0 +uvicorn==0.22.0 channels==4.0.0 -openai==0.27.2 -slack-sdk==3.20.2 -celery==5.2.7 \ No newline at end of file +openai==0.27.8 +slack-sdk==3.21.3 +celery==5.3.1 +django_celery_beat==2.5.0 +psycopg-binary==3.1.9 +psycopg-c==3.1.9 \ No newline at end of file diff --git a/apiserver/requirements/local.txt b/apiserver/requirements/local.txt index efd74a071bd..426236ed812 100644 --- a/apiserver/requirements/local.txt +++ b/apiserver/requirements/local.txt @@ -1,3 +1,3 @@ -r base.txt -django-debug-toolbar==3.8.1 \ No newline at end of file +django-debug-toolbar==4.1.0 \ No newline at end of file diff --git a/apiserver/requirements/production.txt b/apiserver/requirements/production.txt index c37e98ffd41..4da619d491b 100644 --- a/apiserver/requirements/production.txt +++ b/apiserver/requirements/production.txt @@ -1,12 +1,11 @@ -r base.txt -dj-database-url==1.2.0 +dj-database-url==2.0.0 gunicorn==20.1.0 -whitenoise==6.3.0 +whitenoise==6.5.0 django-storages==1.13.2 -boto3==1.26.136 -django-anymail==9.0 -twilio==7.16.2 -django-debug-toolbar==3.8.1 -gevent==22.10.2 +boto3==1.27.0 +django-anymail==10.0 +django-debug-toolbar==4.1.0 +gevent==23.7.0 psycogreen==1.0.2 \ No newline at end of file diff --git a/apps/app/Dockerfile.web b/apps/app/Dockerfile.web index 1b9bc41d5d9..e0b5f29c1e8 100644 --- a/apps/app/Dockerfile.web +++ b/apps/app/Dockerfile.web @@ -20,7 +20,7 @@ ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 COPY .gitignore .gitignore COPY --from=builder /app/out/json/ . COPY --from=builder /app/out/yarn.lock ./yarn.lock -RUN yarn install +RUN yarn install --network-timeout 500000 # Build the project COPY --from=builder /app/out/full/ . diff --git a/apps/app/components/account/email-code-form.tsx b/apps/app/components/account/email-code-form.tsx index e1b7aea9884..1e68cbb294a 100644 --- a/apps/app/components/account/email-code-form.tsx +++ b/apps/app/components/account/email-code-form.tsx @@ -32,6 +32,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => { setError, setValue, getValues, + watch, formState: { errors, isSubmitting, isValid, isDirty }, } = useForm({ defaultValues: { @@ -112,43 +113,35 @@ export const EmailCodeForm = ({ handleSignIn }: any) => { return ( <> -
- {(codeSent || codeResent) && ( -
-
-
-
-
-

- {codeResent - ? "Please check your mail for new code." - : "Please check your mail for code."} -

-
-
-
- )} -
+ {(codeSent || codeResent) && ( +

+ We have sent the sign in code. +
+ Please check your inbox at {watch("email")} +

+ )} + +
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( value - ) || "Email ID is not valid", + ) || "Email address is not valid", }} error={errors.email} - placeholder="Enter your Email ID" + placeholder="Enter your email address..." + className="border-custom-border-300 h-[46px]" />
{codeSent && ( -
+ <> { required: "Code is required", }} error={errors.token} - placeholder="Enter code" + placeholder="Enter code..." + className="border-custom-border-300 h-[46px]" /> -
+ + )} + {codeSent ? ( + + {isLoading ? "Signing in..." : "Sign in"} + + ) : ( + { + handleSubmit(onSubmit)().then(() => { + setResendCodeTimer(30); + }); + }} + disabled={!isValid && isDirty} + loading={isSubmitting} + > + {isSubmitting ? "Sending code..." : "Send sign in code"} + )} -
- {codeSent ? ( - - {isLoading ? "Signing in..." : "Sign in"} - - ) : ( - { - handleSubmit(onSubmit)().then(() => { - setResendCodeTimer(30); - }); - }} - loading={isSubmitting || (!isValid && isDirty)} - > - {isSubmitting ? "Sending code..." : "Send code"} - - )} -
); diff --git a/apps/app/components/account/email-password-form.tsx b/apps/app/components/account/email-password-form.tsx index 8a0dc3a339c..bb341b37140 100644 --- a/apps/app/components/account/email-password-form.tsx +++ b/apps/app/components/account/email-password-form.tsx @@ -8,7 +8,7 @@ import { useForm } from "react-hook-form"; // components import { EmailResetPasswordForm } from "components/account"; // ui -import { Input, SecondaryButton } from "components/ui"; +import { Input, PrimaryButton } from "components/ui"; // types type EmailPasswordFormValues = { email: string; @@ -42,28 +42,39 @@ export const EmailPasswordForm: React.FC = ({ onSubmit }) => { return ( <> +

+ {isResettingPassword + ? "Reset your password" + : isSignUpPage + ? "Sign up on Plane" + : "Sign in to Plane"} +

{isResettingPassword ? ( ) : ( -
-
+ +
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( value - ) || "Email ID is not valid", + ) || "Email address is not valid", }} error={errors.email} - placeholder="Enter your email ID" + placeholder="Enter your email address..." + className="border-custom-border-300 h-[46px]" />
-
+
= ({ onSubmit }) => { required: "Password is required", }} error={errors.password} - placeholder="Enter your password" + placeholder="Enter your password..." + className="border-custom-border-300 h-[46px]" />
-
-
- {isSignUpPage ? ( - - - Already have an account? Sign in. - - - ) : ( - - )} -
+
+ {isSignUpPage ? ( + + + Already have an account? Sign in. + + + ) : ( + + )}
-
- + {isSignUpPage ? isSubmitting ? "Signing up..." - : "Sign Up" + : "Sign up" : isSubmitting ? "Signing in..." - : "Sign In"} - + : "Sign in"} + {!isSignUpPage && ( - + Don{"'"}t have an account? Sign up. diff --git a/apps/app/components/account/email-reset-password-form.tsx b/apps/app/components/account/email-reset-password-form.tsx index 03ea6904209..3717e88012f 100644 --- a/apps/app/components/account/email-reset-password-form.tsx +++ b/apps/app/components/account/email-reset-password-form.tsx @@ -59,32 +59,36 @@ export const EmailResetPasswordForm: React.FC = ({ setIsResettingPassword }; return ( - -
+ +
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( value - ) || "Email ID is not valid", + ) || "Email address is not valid", }} error={errors.email} - placeholder="Enter registered Email ID" + placeholder="Enter registered email address.." + className="border-custom-border-300 h-[46px]" />
-
+
setIsResettingPassword(false)} > Go Back - + {isSubmitting ? "Sending link..." : "Send reset link"}
diff --git a/apps/app/components/account/github-login-button.tsx b/apps/app/components/account/github-login-button.tsx index d997b3a3de0..2f4fcbc4d92 100644 --- a/apps/app/components/account/github-login-button.tsx +++ b/apps/app/components/account/github-login-button.tsx @@ -1,9 +1,14 @@ import { useEffect, useState, FC } from "react"; + import Link from "next/link"; import Image from "next/image"; import { useRouter } from "next/router"; + +// next-themes +import { useTheme } from "next-themes"; // images -import githubImage from "/public/logos/github-black.png"; +import githubBlackImage from "/public/logos/github-black.png"; +import githubWhiteImage from "/public/logos/github-white.png"; const { NEXT_PUBLIC_GITHUB_ID } = process.env; @@ -11,15 +16,15 @@ export interface GithubLoginButtonProps { handleSignIn: React.Dispatch; } -export const GithubLoginButton: FC = (props) => { - const { handleSignIn } = props; - // router +export const GithubLoginButton: FC = ({ handleSignIn }) => { + const [loginCallBackURL, setLoginCallBackURL] = useState(undefined); + const [gitCode, setGitCode] = useState(null); + const { query: { code }, } = useRouter(); - // states - const [loginCallBackURL, setLoginCallBackURL] = useState(undefined); - const [gitCode, setGitCode] = useState(null); + + const { theme } = useTheme(); useEffect(() => { if (code && !gitCode) { @@ -35,13 +40,18 @@ export const GithubLoginButton: FC = (props) => { }, []); return ( -
+
-
diff --git a/apps/app/components/account/google-login.tsx b/apps/app/components/account/google-login.tsx index c12fb4e24ff..67a77f2e483 100644 --- a/apps/app/components/account/google-login.tsx +++ b/apps/app/components/account/google-login.tsx @@ -1,5 +1,5 @@ import { FC, CSSProperties, useEffect, useRef, useCallback, useState } from "react"; -// next + import Script from "next/script"; export interface IGoogleLoginButton { @@ -8,18 +8,18 @@ export interface IGoogleLoginButton { styles?: CSSProperties; } -export const GoogleLoginButton: FC = (props) => { - const { handleSignIn } = props; - +export const GoogleLoginButton: FC = ({ handleSignIn }) => { const googleSignInButton = useRef(null); const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false); const loadScript = useCallback(() => { if (!googleSignInButton.current || gsiScriptLoaded) return; + window?.google?.accounts.id.initialize({ client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "", callback: handleSignIn, }); + window?.google?.accounts.id.renderButton( googleSignInButton.current, { @@ -27,11 +27,13 @@ export const GoogleLoginButton: FC = (props) => { theme: "outline", size: "large", logo_alignment: "center", - width: "410", - text: "continue_with", + width: "360", + text: "signin_with", } as GsiButtonConfiguration // customization attributes ); + window?.google?.accounts.id.prompt(); // also display the One Tap dialog + setGsiScriptLoaded(true); }, [handleSignIn, gsiScriptLoaded]); @@ -48,7 +50,7 @@ export const GoogleLoginButton: FC = (props) => { <>